├── proto
├── node_modules
├── .gitignore
├── package.json
├── tsconfig.json
├── yarn.lock
├── README.md
├── smash.d.ts
└── gen.ts
├── docs
├── _config.yml
├── install.md
├── index.md
├── faq.md
├── design.md
├── development.md
└── related.md
├── cli
├── .gitignore
├── go.mod
├── bash
│ ├── aliases_test.go
│ ├── demo
│ │ └── demo.go
│ ├── aliases.go
│ └── complete.go
├── cmd
│ ├── termdump
│ │ └── termdump.go
│ ├── slowpipe
│ │ └── slowpipe.go
│ └── smash
│ │ ├── localsock.go
│ │ └── smash.go
├── go.sum
├── vt100
│ ├── terminal_test.go
│ └── terminal.go
└── proto
│ └── smash.go
├── .prettierrc.toml
├── web
├── .gitignore
├── dist
│ ├── 144.png
│ ├── 192.png
│ ├── favicon.png
│ ├── test.html
│ ├── worker.js
│ ├── local.html
│ ├── index.html
│ ├── manifest.json
│ └── style.css
├── src
│ ├── widgets.ts
│ ├── local.ts
│ ├── history.ts
│ ├── html.ts
│ ├── alias.ts
│ ├── path.ts
│ ├── path_test.ts
│ ├── server.ts
│ ├── shell_test.ts
│ ├── test.ts
│ ├── tabs.ts
│ ├── smash.ts
│ ├── readline_test.ts
│ ├── shell.ts
│ ├── connection.ts
│ ├── term.ts
│ ├── cells.ts
│ ├── proto.ts
│ └── readline.ts
├── tsconfig.json
└── package.json
├── fmt.sh
├── README.md
├── watch.sh
├── Makefile
└── LICENSE
/proto/node_modules:
--------------------------------------------------------------------------------
1 | ../web/node_modules/
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-slate
--------------------------------------------------------------------------------
/proto/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | *.js
3 |
--------------------------------------------------------------------------------
/cli/.gitignore:
--------------------------------------------------------------------------------
1 | /smash
2 | /slowpipe
3 | /termdump
4 |
--------------------------------------------------------------------------------
/.prettierrc.toml:
--------------------------------------------------------------------------------
1 | singleQuote = true
2 | proseWrap = "always"
3 |
--------------------------------------------------------------------------------
/web/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist/smash.js
3 | /dist/*.map
4 | /js
5 |
--------------------------------------------------------------------------------
/web/dist/144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evmar/smash/HEAD/web/dist/144.png
--------------------------------------------------------------------------------
/web/dist/192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evmar/smash/HEAD/web/dist/192.png
--------------------------------------------------------------------------------
/web/dist/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evmar/smash/HEAD/web/dist/favicon.png
--------------------------------------------------------------------------------
/fmt.sh:
--------------------------------------------------------------------------------
1 | prettier=web/node_modules/.bin/prettier
2 | exec $prettier *.md docs/*.md web/src/**.ts web/dist/{index.html,style.css,manifest.json} "$@"
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # smash, a new kind of terminal
2 |
3 | See the documentation starting at `docs/index.md`, or read it online at
4 | http://evmar.github.io/smash/.
5 |
--------------------------------------------------------------------------------
/web/src/widgets.ts:
--------------------------------------------------------------------------------
1 | import { ReadLine } from './readline';
2 |
3 | export const exported = {
4 | ReadLine,
5 | };
6 |
7 | (window as any)['smash'] = exported;
8 |
--------------------------------------------------------------------------------
/web/dist/test.html:
--------------------------------------------------------------------------------
1 |
2 |
smash widgets demo
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017",
4 | "module": "commonjs",
5 | "sourceMap": true,
6 | "outDir": "./js",
7 | "strict": true
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/proto/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "proto",
3 | "version": "1.0.0",
4 | "dependencies": {
5 | "typescript": "^3.8.3"
6 | },
7 | "devDependencies": {
8 | "@types/node": "^13.13.4"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/web/dist/worker.js:
--------------------------------------------------------------------------------
1 | // This is required for 'add to homescreen' to work.
2 | this.addEventListener('fetch', function(event) {
3 | console.log('fetch', event);
4 | // event.respondWith(fetch(event.request));
5 | });
6 |
--------------------------------------------------------------------------------
/web/dist/local.html:
--------------------------------------------------------------------------------
1 |
2 | smash widgets demo
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/web/dist/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/cli/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/evmar/smash
2 |
3 | require (
4 | github.com/golang/protobuf v1.2.1-0.20190205222052-c823c79ea157
5 | github.com/gorilla/websocket v1.4.0
6 | github.com/kr/pty v1.1.3
7 | github.com/stretchr/testify v1.3.0
8 | )
9 |
10 | go 1.13
11 |
--------------------------------------------------------------------------------
/web/src/local.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Entry point for JS-only widget interaction demo.
3 | */
4 |
5 | import { ReadLine } from './readline';
6 | import { History } from './history';
7 |
8 | function main() {
9 | const readline = new ReadLine(new History());
10 | document.body.appendChild(readline.dom);
11 | }
12 |
13 | main();
14 |
--------------------------------------------------------------------------------
/docs/install.md:
--------------------------------------------------------------------------------
1 | # Install
2 |
3 | Smash is still a work in progress, so you must build from source and there is no
4 | install step.
5 |
6 | See also [development notes](development.md).
7 |
8 | ## Prereqs
9 |
10 | - golang
11 | - node+yarn
12 |
13 | ## Building
14 |
15 | ```sh
16 | $ (cd web && yarn) # Install prerequisites.
17 | $ make run
18 | ```
19 |
--------------------------------------------------------------------------------
/watch.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | shopt -s globstar
4 | set -e
5 |
6 | FILES=$(ls cli/**/*.go proto/*.js web/src/*.ts web/dist/*.{html,css})
7 | inotifywait -m -e close_write $FILES | \
8 | while read; do
9 | if make all; then
10 | cd cli && ./smash &
11 | pid=$!
12 | read status
13 | kill $pid
14 | else
15 | read status
16 | fi
17 | echo $status
18 | done
19 |
--------------------------------------------------------------------------------
/web/dist/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "smash",
3 | "short_name": "smash",
4 | "icons": [
5 | {
6 | "src": "144.png",
7 | "type": "image/png",
8 | "sizes": "144x144"
9 | },
10 | {
11 | "src": "192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | }
15 | ],
16 | "start_url": "/",
17 | "display": "standalone"
18 | }
19 |
--------------------------------------------------------------------------------
/cli/bash/aliases_test.go:
--------------------------------------------------------------------------------
1 | package bash
2 |
3 | import "testing"
4 |
5 | func TestParseAliases(t *testing.T) {
6 | aliases, err := parseAliases("alias foo='bar'\nalias bar='baz'\n")
7 | if err != nil {
8 | t.Fatal(err)
9 | }
10 | if len(aliases) != 2 {
11 | t.Errorf("want 2 aliases, got %q", aliases)
12 | }
13 | if aliases["foo"] != "bar" || aliases["bar"] != "baz" {
14 | t.Errorf("wanted foo=bar, bar=baz aliases, got %q", aliases)
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/proto/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
5 | "strict": true /* Enable all strict type-checking options. */
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/cli/cmd/termdump/termdump.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "io"
7 | "log"
8 | "os"
9 |
10 | "github.com/evmar/smash/vt100"
11 | )
12 |
13 | func main() {
14 | term := vt100.NewTerminal()
15 | tr := vt100.NewTermReader(func(f func(t *vt100.Terminal)) {
16 | f(term)
17 | })
18 |
19 | r := bufio.NewReader(os.Stdin)
20 | var err error
21 | for err == nil {
22 | err = tr.Read(r)
23 | }
24 | if err != nil && err != io.EOF {
25 | log.Println("ERROR:", err)
26 | os.Exit(1)
27 | }
28 | fmt.Println(term.ToString())
29 | }
30 |
--------------------------------------------------------------------------------
/web/src/history.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Manages the history of previously executed commands.
3 | */
4 | export class History {
5 | private entries: string[] = [];
6 |
7 | add(cmd: string) {
8 | cmd = cmd.trim();
9 | // Avoid empty entries.
10 | if (cmd === '') return;
11 | // Avoid duplicate entries.
12 | if (this.entries.length > 0 && this.get(1) === cmd) return;
13 | this.entries.push(cmd);
14 | }
15 |
16 | get(ofs: number): string | undefined {
17 | if (ofs > this.entries.length) return;
18 | return this.entries[this.entries.length - ofs];
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/cli/cmd/slowpipe/slowpipe.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "io"
7 | "os"
8 | "time"
9 | )
10 |
11 | func run() error {
12 | r := bufio.NewReader(os.Stdin)
13 | for {
14 | c, err := r.ReadByte()
15 | if err != nil {
16 | if err == io.EOF {
17 | return nil
18 | }
19 | return err
20 | }
21 | _, err = os.Stdout.Write([]byte{c})
22 | if err != nil {
23 | return err
24 | }
25 | time.Sleep(50 * time.Millisecond)
26 | }
27 | }
28 |
29 | func main() {
30 | if err := run(); err != nil {
31 | fmt.Fprintf(os.Stderr, "slowpipe: %s", err)
32 | os.Exit(1)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/proto/yarn.lock:
--------------------------------------------------------------------------------
1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
2 | # yarn lockfile v1
3 |
4 |
5 | "@types/node@^13.13.4":
6 | version "13.13.4"
7 | resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.4.tgz#1581d6c16e3d4803eb079c87d4ac893ee7501c2c"
8 | integrity sha512-x26ur3dSXgv5AwKS0lNfbjpCakGIduWU1DU91Zz58ONRWrIKGunmZBNv4P7N+e27sJkiGDsw/3fT4AtsqQBrBA==
9 |
10 | typescript@^3.8.3:
11 | version "3.8.3"
12 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061"
13 | integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==
14 |
--------------------------------------------------------------------------------
/web/src/html.ts:
--------------------------------------------------------------------------------
1 | export function html(
2 | tagName: string,
3 | attr: { [key: string]: {} } = {},
4 | ...children: Node[]
5 | ) {
6 | const tag = document.createElement(tagName);
7 | for (const key in attr) {
8 | if (key === 'style') {
9 | const style = attr[key] as { [key: string]: {} };
10 | for (const key in style) {
11 | (tag.style as any)[key] = style[key];
12 | }
13 | } else {
14 | (tag as any)[key] = attr[key];
15 | }
16 | }
17 | for (const child of children) {
18 | tag.appendChild(child);
19 | }
20 | return tag;
21 | }
22 |
23 | export function htext(text: string): Node {
24 | return document.createTextNode(text);
25 | }
26 |
--------------------------------------------------------------------------------
/cli/bash/demo/demo.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/evmar/smash/bash"
10 | )
11 |
12 | func main() {
13 | b, err := bash.StartBash()
14 | if err != nil {
15 | log.Fatalf("start failed: %s", err)
16 | }
17 |
18 | s := bufio.NewScanner(os.Stdin)
19 | for {
20 | fmt.Printf("text to complete> ")
21 | if !s.Scan() {
22 | break
23 | }
24 | _, exps, err := b.Complete(s.Text())
25 | if err != nil {
26 | log.Fatalf("run failed: %s", err)
27 | }
28 | for _, exp := range exps {
29 | fmt.Printf(" %q\n", exp)
30 | }
31 | }
32 | if err := s.Err(); err != nil {
33 | log.Fatalf("scan: %s", err)
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/web/src/alias.ts:
--------------------------------------------------------------------------------
1 | import { parseCmd } from './shell';
2 |
3 | export class AliasMap {
4 | aliases = new Map();
5 |
6 | replaceAll(aliases: Map) {
7 | this.aliases = aliases;
8 | }
9 |
10 | set(alias: string, expansion: string) {
11 | this.aliases.set(alias, expansion);
12 | }
13 |
14 | expand(cmd: string): string {
15 | const first = cmd.split(' ')[0];
16 | let exp = this.aliases.get(first);
17 | if (!exp) return cmd;
18 | return exp + cmd.substring(first.length);
19 | }
20 |
21 | dump(): string {
22 | return Array.from(this.aliases.entries())
23 | .map(([k, v]) => `alias ${k}='${v}'\n`)
24 | .join('');
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "smash",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "license": "Apache-2.0",
6 | "devDependencies": {
7 | "@types/chai": "^4.1.7",
8 | "@types/mocha": "^7.0.2",
9 | "@types/node": "^13.13.0",
10 | "@types/puppeteer": "^2.0.1",
11 | "chai": "^4.2.0",
12 | "esbuild-linux-64": "^0.6.3",
13 | "mocha": "^7.1.1",
14 | "prettier": "^2.0.4",
15 | "puppeteer": "^3.0.0",
16 | "source-map-explorer": "^2.4.2",
17 | "source-map-loader": "^0.2.4",
18 | "typescript": "~4.0"
19 | },
20 | "dependencies": {},
21 | "scripts": {
22 | "browser-test": "mocha js/test.js",
23 | "local-test": "mocha js/path_test.js js/readline_test.js js/shell_test.js"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/web/src/path.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Replacements for node's "path" module.
3 | */
4 |
5 | export function join(a: string, b: string): string {
6 | if (b.startsWith('/')) return b;
7 | if (a.endsWith('/')) return a;
8 | return normalize(`${a}/${b}`);
9 | }
10 |
11 | export function normalize(path: string): string {
12 | const partsIn = path.split('/');
13 | const partsOut: string[] = [];
14 | for (const part of partsIn) {
15 | switch (part) {
16 | case '':
17 | if (partsOut.length > 0) continue;
18 | break;
19 | case '.':
20 | if (partsOut.length > 0) continue;
21 | break;
22 | case '..':
23 | if (partsOut.length > 0) {
24 | partsOut.pop();
25 | continue;
26 | }
27 | break;
28 | }
29 | partsOut.push(part);
30 | }
31 | return partsOut.join('/');
32 | }
33 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | All day long I work using a shell under a terminal emulator -- a combination of
2 | software that hasn't changed much in 30 years. Why? I use the command line
3 | because it is powerful and expressive and because all the other tools I use work
4 | well with it. But at the same time it seems we ought be able to improve on the
5 | interface while keeping the fundamental idea.
6 |
7 | Smash is an attempt to improve the textual user interface while preserving the
8 | the good parts. Reasonable people can disagree over what exactly that means. If
9 | this whole idea makes you upset please see the first question in the
10 | [FAQ](faq.md).
11 |
12 | Next, read:
13 |
14 | - [Project design](design.md)
15 | - [FAQ](faq.md)
16 | - [Related work](related.md)
17 |
18 | To try it out:
19 |
20 | - [Install instructions](install.md)
21 | - [Development notes](development.md)
22 |
--------------------------------------------------------------------------------
/web/src/path_test.ts:
--------------------------------------------------------------------------------
1 | import * as path from './path';
2 | import { expect } from 'chai';
3 |
4 | describe('path', () => {
5 | describe('join', () => {
6 | it('joins relatively', () => {
7 | expect(path.join('a', 'b')).equal('a/b');
8 | });
9 |
10 | it('obeys parents', () => {
11 | expect(path.join('a/b', '../c')).equal('a/c');
12 | });
13 | });
14 |
15 | describe('normalize', () => {
16 | it('leaves ok paths alone', () => {
17 | expect(path.normalize('/foo/bar')).equal('/foo/bar');
18 | expect(path.normalize('foo/bar')).equal('foo/bar');
19 | });
20 |
21 | it('normalizes double slash', () => {
22 | expect(path.normalize('a//b')).equal('a/b');
23 | });
24 |
25 | it('normalizes dot', () => {
26 | expect(path.normalize('./bar')).equal('./bar');
27 | expect(path.normalize('foo/./bar')).equal('foo/bar');
28 | });
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/cli/bash/aliases.go:
--------------------------------------------------------------------------------
1 | package bash
2 |
3 | import (
4 | "fmt"
5 | "os/exec"
6 | "regexp"
7 | )
8 |
9 | func parseAliases(text string) (map[string]string, error) {
10 | if text == "" {
11 | return map[string]string{}, nil
12 | }
13 | re := regexp.MustCompile(`(?m)^alias (\w+)='(.*?)'\n`)
14 | match := re.FindAllStringSubmatch(text, -1)
15 | if match == nil {
16 | return nil, fmt.Errorf("couldn't parse aliases %q", text)
17 | }
18 | aliases := map[string]string{}
19 | for _, row := range match {
20 | if len(row) != 3 {
21 | return nil, fmt.Errorf("bad alias output %q", row)
22 | }
23 | aliases[row[1]] = row[2]
24 | }
25 | return aliases, nil
26 | }
27 |
28 | // GetAliases shells out to bash to extract the user's configured alias list.
29 | func GetAliases() (map[string]string, error) {
30 | cmd := exec.Command("bash", "-i", "-c", "alias")
31 | out, err := cmd.Output()
32 | if err != nil {
33 | return nil, err
34 | }
35 | return parseAliases(string(out))
36 | }
37 |
--------------------------------------------------------------------------------
/web/src/server.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Basic web server, used in tests and in local dev.
3 | */
4 |
5 | import * as fs from 'fs';
6 | import * as http from 'http';
7 | import * as path from 'path';
8 | import * as url from 'url';
9 |
10 | export const port = 9001;
11 |
12 | export function runServer(): Promise {
13 | const server = http.createServer((req, res) => {
14 | const reqUrl = url.parse(req.url || '/');
15 | let reqPath = path.normalize(reqUrl.path || '/');
16 | if (reqPath.endsWith('/')) reqPath += 'index.html';
17 | if (!reqPath.startsWith('/')) {
18 | console.error('bad request', reqPath);
19 | throw new Error('bad request');
20 | }
21 | reqPath = path.join('dist', reqPath);
22 |
23 | const file = fs.createReadStream(reqPath);
24 | file.pipe(res);
25 | });
26 | server.listen(port);
27 | return new Promise((resolve) => {
28 | server.on('listening', () => {
29 | console.log(`test server listening on ${port}`);
30 | resolve(server);
31 | });
32 | });
33 | }
34 |
35 | if (require.main === module) {
36 | runServer();
37 | }
38 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all run
2 | all: cli/smash web/dist/smash.js
3 |
4 | run: all
5 | cd cli && ./smash
6 |
7 | cli/smash: cli/cmd/smash/*.go cli/proto/smash.go cli/vt100/terminal.go cli/bash/aliases.go cli/bash/complete.go
8 | cd cli && go build github.com/evmar/smash/cmd/smash
9 |
10 | webts=$(wildcard web/src/*.ts)
11 |
12 | web/dist/smash.js: web/package.json $(webts)
13 | web/node_modules/.bin/esbuild --target=es2019 --bundle --sourcemap --outfile=$@ web/src/smash.ts
14 |
15 | # Build the proto generator from the TypeScript source.
16 | proto/gen.js: proto/*.ts
17 | cd proto && yarn run tsc
18 |
19 | # Build the proto output using the proto generator.
20 | web/src/proto.ts: proto/gen.js proto/smash.d.ts
21 | node proto/gen.js ts proto/smash.d.ts > web/src/proto.ts
22 | cli/proto/smash.go: proto/gen.js proto/smash.d.ts
23 | node proto/gen.js go proto/smash.d.ts > cli/proto/smash.go
24 |
25 | .PHONY: watch
26 | watch:
27 | (cd proto && yarn run tsc --preserveWatchOutput -w & \
28 | cd web && yarn run tsc --preserveWatchOutput -w & \
29 | wait)
30 |
31 | .PHONY: fmt
32 | fmt:
33 | ./fmt.sh --write
34 | (cd cli && go fmt ./...)
35 |
36 | .PHONY: serve
37 | serve:
38 | cd web && node js/server.js
39 |
--------------------------------------------------------------------------------
/docs/faq.md:
--------------------------------------------------------------------------------
1 | # Frequently Asked Questions
2 |
3 | ## Why is [thing X] different than the way it normally works?
4 |
5 | It may be the case that everything as it currently is is already perfect and
6 | should not be changed. However, many good new ideas are only obvious in
7 | retrospect and cannot be discovered in an environment where any deviation from
8 | the status quo is immediately shot down.
9 |
10 | The purpose of this project is to explore new directions for the command line
11 | user interface. If you think that the way things currently work cannot be
12 | improved on, you should just use one of the existing terminal emulators and keep
13 | your criticism to yourself.
14 |
15 | ## Why is smash's UI implemented in HTML?
16 |
17 | I am developing smash on a ChromeOS laptop, where HTML is the native UI
18 | framework. In previous experiments in this area I have have prototyped native
19 | UIs for smash in both Go and Rust, and in the latter I even went as far as
20 | building native Linux and Win32 frontends; see the (obsolete) branches in the
21 | github repository. (With a native app developer's perspective I would add that
22 | HTML provides many convenient tools for rapid iteration on a prototype.)
23 |
--------------------------------------------------------------------------------
/proto/README.md:
--------------------------------------------------------------------------------
1 | # protocol generator
2 |
3 | This used to use Google protobufs, but I switched to my own thing for fun.
4 |
5 | `smash.d.ts` is used as a schema that decribes the protocol. Note that it
6 | isn't the actual types used in either TS or Go, but rather we reuse TS for
7 | the parser and some semantic verification.
8 |
9 | The wire format is currently undocumented while I figure out the details, but
10 | it's a pretty straightforward serialization.
11 |
12 | ## Design goals
13 |
14 | - Restrict the input format to make serialization/deserialization easy. We don't
15 | need to support the full type system of TypeScript.
16 | - No versioning-related negotiation; we can assume the client and server always
17 | were built from exactly the same version of the code.
18 | - Generate native-feeling code in each language, even if that means the per-language
19 | generated APIs don't match each other.
20 |
21 | ## Design notes
22 |
23 | ### TypeScript
24 |
25 | I wanted to make enumerated types ("Foo is either A or B") into plain unions in
26 | TypeScript:
27 |
28 | ```
29 | type Foo = A | B;
30 | ```
31 |
32 | But this ends up failing because when you want to send such a mesage, you
33 | want to mark which arm you chose, so the message-sending function needs some
34 | runtime representation of `Foo` as distinct from `A` and `B`.
--------------------------------------------------------------------------------
/web/src/shell_test.ts:
--------------------------------------------------------------------------------
1 | import { Shell, ExecOutput, parseCmd } from './shell';
2 | import { expect } from 'chai';
3 |
4 | async function fakeExec(out: ExecOutput): Promise {
5 | if (out.kind !== 'remote') return;
6 | return new Promise((res, rej) => {
7 | out.onComplete?.(0);
8 | res();
9 | });
10 | }
11 |
12 | describe('shell', async function () {
13 | const env = new Map([['HOME', '/home/evmar']]);
14 |
15 | describe('parser', function () {
16 | it('parses simple input', function () {
17 | expect(parseCmd('')).deep.equal([]);
18 | expect(parseCmd('cd')).deep.equal(['cd']);
19 | expect(parseCmd('cd foo/bar')).deep.equal(['cd', 'foo/bar']);
20 | });
21 |
22 | it('ignores whitespace', function () {
23 | expect(parseCmd('cd ')).deep.equal(['cd']);
24 | expect(parseCmd('cd foo/bar zz')).deep.equal(['cd', 'foo/bar', 'zz']);
25 | });
26 | });
27 |
28 | it('elides homedir', function () {
29 | const sh = new Shell(env);
30 | sh.cwd = '/home/evmar';
31 | expect(sh.cwdForPrompt()).equal('~');
32 | sh.cwd = '/home/evmar/test';
33 | expect(sh.cwdForPrompt()).equal('~/test');
34 | });
35 |
36 | describe('cd', function () {
37 | it('goes home', async function () {
38 | const sh = new Shell(env);
39 | await fakeExec(sh.builtinCd([]));
40 | expect(sh.cwd).equal('/home/evmar');
41 | });
42 |
43 | it('normalizes paths', async function () {
44 | const sh = new Shell(env);
45 | await fakeExec(sh.builtinCd([]));
46 | await fakeExec(sh.builtinCd(['foo//bar/']));
47 | expect(sh.cwd).equal('/home/evmar/foo/bar');
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/cli/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/golang/protobuf v1.2.1-0.20190205222052-c823c79ea157 h1:SdQMHsZ18/XZCHuwt3IF+dvHgYTO2XMWZjv3XBKQqAI=
4 | github.com/golang/protobuf v1.2.1-0.20190205222052-c823c79ea157/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0=
5 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
6 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
7 | github.com/kr/pty v1.1.3 h1:/Um6a/ZmD5tF7peoOJ5oN5KMQ0DrGVQSXLNwyckutPk=
8 | github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
11 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
12 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
13 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
14 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
15 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
16 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA=
17 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
18 | google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
19 |
--------------------------------------------------------------------------------
/docs/design.md:
--------------------------------------------------------------------------------
1 | # Design
2 |
3 | Smash merges the shell, terminal emulator, and client-side app. Smash tries to
4 | not replace the shell semantics: commands are still the unix commands, pipelines
5 | are still pipelines, tab completion still comes from bash.
6 |
7 | The UX goals of smash are:
8 |
9 | - No latency on "client-side" interactions like moving the cursor within the
10 | prompt, even when connected to a remote host.
11 | - Support the mouse where appropriate.
12 | - Window management, e.g. tabs.
13 | - Native interactions for job control and executed subcommands; e.g. native
14 | popup for tab completion, native scrollbars for scrollback, and integrations
15 | like "run this command in a new tab". Concurrently running commands never
16 | interleave their output.
17 | - Terminal state is persisted server-side, so disconnecting and reconnecting
18 | resumes your session.
19 | - Preserve most of the keystrokes used in an ordinary bash.
20 |
21 | Together, these goals produced this design:
22 |
23 | - The client implments the shell prompt, keyboard interactions, windowing etc.
24 | - The server executes commands and implements terminal emulation. (Otherwise you
25 | couldn't restore the screen state when the client reconnects.)
26 | - The two communicate via a custom protocol (command to execute in one
27 | direction, render output in the other).
28 |
29 | The smash server process interprets the terminal output, which means it can
30 | expose that output back to the shell.
31 |
32 | ## Deferred work
33 |
34 | I am definitely interested in revisiting the semantics of commands, like how
35 | pipelines work, but I am attempting to limit scope to something tractable to
36 | complete. See the [related work](related.md) for some inspiration.
37 |
--------------------------------------------------------------------------------
/docs/development.md:
--------------------------------------------------------------------------------
1 | # Development notes
2 |
3 | While developing, in one terminal:
4 |
5 | ```sh
6 | $ make watch # watch frontend code and print errors
7 | ```
8 |
9 | And then in another:
10 |
11 | ```sh
12 | $ ./watch # build in a loop; restarts on changes
13 | ```
14 |
15 | Now reloading the page reloads the content.
16 |
17 | ## Formatter
18 |
19 | ```sh
20 | $ make fmt
21 | ```
22 |
23 | To run prettier+gofmt, which is checked on presubmit.
24 |
25 | ## Protocol changes
26 |
27 | ```sh
28 | $ make proto
29 | ```
30 |
31 | Regenerates the generated protocol code.
32 |
33 | ## Testing
34 |
35 | HTML/JS-only tests are in `web/src/test.ts`, driving a headless Chrome:
36 |
37 | ```sh
38 | $ cd web; npm run test
39 | ```
40 |
41 | Go tests use the Go test runner:
42 |
43 | ```sh
44 | $ cd cli; go test ./...
45 | ```
46 |
47 | To bring up a test page to poke in a browser:
48 |
49 | ```sh
50 | $ make serve
51 | ```
52 |
53 | and visit `http://localhost:9001/local.html`.
54 |
55 | ## Chrome PWA
56 |
57 | PWAs only work on https or localhost. For one of these on ChromeOS, the best
58 | option seems to be connection forwarding using
59 | [Connection Forwarder](https://chrome.google.com/webstore/detail/connection-forwarder/ahaijnonphgkgnkbklchdhclailflinn)
60 | to forward localhost into the crostini IP.
61 |
62 | Update: digging in the Chrome source suggests that on ChromeOS specifically,
63 | Chrome also treats penguin.linux.test as a trusted domain. However, I've never
64 | been able to make the Chrome PWA bits work on ChromeOS (localhost or
65 | penguin.linux.test) so I'll leave the previous paragraph here until I'm
66 | confident of the resolution.
67 |
68 | ## The icon
69 |
70 | ```sh
71 | $ convert -size 32x32 -gravity center -background white -fill black label:">" icon.png
72 | ```
73 |
74 | ## vt100
75 |
76 | Run `script` then the command to capture raw terminal output.
77 |
78 | Run `infocmp -L` to understand what the terminal outputs mean.
79 |
80 | ## bash
81 |
82 | To experiment with the bash completion support, run:
83 |
84 | ```sh
85 | $ cd cli && go run ./bash/demo
86 | ```
87 |
--------------------------------------------------------------------------------
/web/src/test.ts:
--------------------------------------------------------------------------------
1 | import * as readline from './readline';
2 | import { ReadLine } from './readline';
3 | import { expect } from 'chai';
4 | import * as http from 'http';
5 | import * as puppeteer from 'puppeteer';
6 |
7 | import { runServer, port } from './server';
8 |
9 | let server: http.Server;
10 | let browser: puppeteer.Browser;
11 |
12 | before(async () => {
13 | server = await runServer();
14 | browser = await puppeteer.launch({
15 | // headless: false,
16 | // slowMo: 500,
17 | });
18 | });
19 |
20 | declare const smash: typeof import('./widgets').exported;
21 |
22 | after(async () => {
23 | await browser.close();
24 | server.close();
25 | });
26 |
27 | describe('readline', async function () {
28 | let page: puppeteer.Page;
29 | let readline: puppeteer.JSHandle;
30 |
31 | beforeEach(async () => {
32 | page = await browser.newPage();
33 | await page.goto(`http://localhost:${port}/test.html`);
34 | readline = await page.evaluateHandle(() => {
35 | const historyStub: readline.History = {
36 | add() {},
37 | get() {
38 | return undefined;
39 | },
40 | };
41 | const readline = new smash.ReadLine(historyStub);
42 | document.body.appendChild(readline.dom);
43 | readline.input.focus();
44 | return readline;
45 | });
46 | });
47 |
48 | function getCursorPos() {
49 | return page.evaluate(
50 | (readline: ReadLine) => readline.input.selectionStart,
51 | readline
52 | );
53 | }
54 |
55 | describe('emacs', () => {
56 | async function typeEmacs(key: string) {
57 | let control = false;
58 | if (key.startsWith('C-')) {
59 | control = true;
60 | key = key.substr(2);
61 | }
62 |
63 | if (control) await page.keyboard.down('Control');
64 | await page.keyboard.type(key);
65 | if (control) await page.keyboard.up('Control');
66 | }
67 |
68 | it('C-a', async () => {
69 | await page.keyboard.type('demo');
70 | expect(await getCursorPos()).equal('demo'.length);
71 | await typeEmacs('C-a');
72 | expect(await getCursorPos()).equal(0);
73 | });
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/web/src/tabs.ts:
--------------------------------------------------------------------------------
1 | import { CellStack } from './cells';
2 | import { html, htext } from './html';
3 | import * as proto from './proto';
4 | import { Shell } from './shell';
5 |
6 | interface Tab {
7 | /** The tab widget itself, as shown in the tab strip. */
8 | dom: HTMLElement;
9 |
10 | /** The contents of the tab, shown when the tab is selected. */
11 | cellStack: CellStack;
12 | }
13 |
14 | export class Tabs {
15 | tabStrip = html('div', { className: 'tabstrip', style: { display: 'none' } });
16 | dom = html('div', { className: 'tabs' }, this.tabStrip);
17 |
18 | tabs: Tab[] = [];
19 | sel = -1;
20 | delegates = {
21 | send: (msg: proto.ClientMessage) => {},
22 | };
23 |
24 | addCells(shell: Shell) {
25 | const tab = this.newTab(shell);
26 | this.tabs.push(tab);
27 | this.tabStrip.appendChild(tab.dom);
28 |
29 | if (this.tabs.length > 1) {
30 | this.tabStrip.style.display = 'flex';
31 | }
32 |
33 | if (this.sel === -1) {
34 | this.showTab(0);
35 | }
36 | }
37 |
38 | private newTab(shell: Shell): Tab {
39 | const dom = html('div', { className: 'tab' }, htext('tab'));
40 | const cellStack = new CellStack(shell);
41 | cellStack.delegates = {
42 | send: (msg) => this.delegates.send(msg),
43 | };
44 | return { dom, cellStack };
45 | }
46 |
47 | handleMessage(msg: proto.ServerMsg): boolean {
48 | const cellStack = this.tabs[0].cellStack;
49 | switch (msg.tag) {
50 | case 'CompleteResponse':
51 | cellStack.getLastCell().onCompleteResponse(msg.val);
52 | return true;
53 | case 'CellOutput':
54 | cellStack.onOutput(msg.val);
55 | return true;
56 | }
57 | return false;
58 | }
59 |
60 | showTab(index: number) {
61 | if (this.sel === index) return;
62 | if (this.sel >= 0) {
63 | this.tabs[this.sel].dom.style.position = 'initial';
64 | this.dom.removeChild(this.dom.lastChild!);
65 | }
66 | this.sel = index;
67 | this.tabs[index].dom.style.position = 'relative';
68 | this.dom.appendChild(this.tabs[index].cellStack.dom);
69 | }
70 |
71 | focus() {
72 | this.tabs[this.sel].cellStack.focus();
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/proto/smash.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file defines the smash client<->server protocol.
3 | * Note that the types as written here are not actually used directly
4 | * in smash, but rather more complex types are derived from them.
5 | * See README.md.
6 | */
7 |
8 | type int = number;
9 | type uint8 = number;
10 |
11 | /** Message from client to server. */
12 | type ClientMessage = CompleteRequest | RunRequest | KeyEvent;
13 |
14 | /** Request to complete a partial command-line input. */
15 | interface CompleteRequest {
16 | id: int;
17 | cwd: string;
18 | input: string;
19 | pos: int;
20 | }
21 |
22 | /** Response to a CompleteRequest. */
23 | interface CompleteResponse {
24 | id: int;
25 | error: string;
26 | pos: int;
27 | completions: string[];
28 | }
29 |
30 | /** Request to spawn a command. */
31 | interface RunRequest {
32 | cell: int;
33 | cwd: string;
34 | argv: string[];
35 | }
36 |
37 | /** Keystroke sent to running command. */
38 | interface KeyEvent {
39 | cell: int;
40 | keys: string;
41 | }
42 |
43 | interface RowSpans {
44 | row: int;
45 | spans: Span[];
46 | }
47 | interface Span {
48 | attr: int;
49 | text: string;
50 | }
51 | interface Cursor {
52 | row: int;
53 | col: int;
54 | hidden: boolean;
55 | }
56 |
57 | /** Termial update, server -> client. */
58 | interface TermUpdate {
59 | /** Updates to specific rows of output. */
60 | rows: RowSpans[];
61 | /** Cursor status. */
62 | cursor: Cursor;
63 | /** Total count of lines in the terminal, may go down on scrolling up. */
64 | rowCount: int;
65 | }
66 |
67 | interface Pair {
68 | key: string;
69 | val: string;
70 | }
71 |
72 | /** Message from server to client on connection. */
73 | interface Hello {
74 | /** Command aliases, from alias name to expansion. */
75 | alias: Pair[];
76 |
77 | /** Environment variables. */
78 | env: Pair[];
79 |
80 | // TODO: running cells and their state.
81 | }
82 |
83 | interface CmdError {
84 | error: string;
85 | }
86 | interface Exit {
87 | exitCode: int;
88 | }
89 | type Output = CmdError | TermUpdate | Exit;
90 |
91 | /** Message from server to client about a running subprocess. */
92 | interface CellOutput {
93 | cell: int;
94 | output: Output;
95 | }
96 |
97 | type ServerMsg = Hello | CompleteResponse | CellOutput;
98 |
--------------------------------------------------------------------------------
/web/src/smash.ts:
--------------------------------------------------------------------------------
1 | import { ServerConnection } from './connection';
2 | import { Shell } from './shell';
3 | import { Tabs } from './tabs';
4 | import { html, htext } from './html';
5 |
6 | const tabs = new Tabs();
7 |
8 | async function connect() {
9 | const conn = new ServerConnection();
10 | const hello = await conn.connect();
11 |
12 | const shell = new Shell();
13 | shell.aliases.replaceAll(
14 | new Map(hello.alias.map(({ key, val }) => [key, val]))
15 | );
16 | shell.env = new Map(hello.env.map(({ key, val }) => [key, val]));
17 | shell.init();
18 | tabs.addCells(shell);
19 | tabs.focus();
20 |
21 | tabs.delegates = {
22 | send: (msg) => conn.send(msg),
23 | };
24 |
25 | return conn;
26 | }
27 |
28 | async function msgLoop(conn: ServerConnection) {
29 | for (;;) {
30 | const msg = await conn.read();
31 | if (!tabs.handleMessage(msg)) {
32 | throw new Error(`unexpected message: ${msg}`);
33 | }
34 | }
35 | }
36 |
37 | async function reconnectPrompt(message: string) {
38 | console.error(message);
39 | let dom!: HTMLElement;
40 | await new Promise((res) => {
41 | dom = html(
42 | 'div',
43 | { className: 'error-popup' },
44 | html('div', {}, htext(message)),
45 | html('div', { style: { width: '1ex' } }),
46 | html(
47 | 'button',
48 | {
49 | onclick: () => {
50 | res();
51 | },
52 | },
53 | htext('reconnect')
54 | )
55 | );
56 | document.body.appendChild(dom);
57 | });
58 | document.body.removeChild(dom);
59 | }
60 |
61 | async function main() {
62 | // Register an unused service worker so 'add to homescreen' works.
63 | // TODO: even when we do this, we still get a URL bar?!
64 | // await navigator.serviceWorker.register('worker.js');
65 |
66 | document.body.appendChild(tabs.dom);
67 |
68 | // Clicking on the page, if it tries to focus the document body,
69 | // should redirect focus to the relevant place in the cell stack.
70 | // This approach feels hacky but I experimented with focus events
71 | // and couldn't get the desired behavior.
72 | document.addEventListener('click', () => {
73 | if (document.activeElement === document.body) {
74 | tabs.focus();
75 | }
76 | });
77 |
78 | for (;;) {
79 | try {
80 | const conn = await connect();
81 | await msgLoop(conn);
82 | } catch (err) {
83 | await reconnectPrompt(err);
84 | }
85 | }
86 | }
87 |
88 | main().catch((err) => {
89 | console.error(err);
90 | });
91 |
--------------------------------------------------------------------------------
/cli/cmd/smash/localsock.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "net"
7 | "os"
8 | "os/signal"
9 | "path/filepath"
10 | "strings"
11 | "syscall"
12 | )
13 |
14 | // handleLocal handles an incoming local connection,
15 | // by reading a command from the connection and writing
16 | // its output to it.
17 | func handleLocal(conn net.Conn) error {
18 | defer conn.Close()
19 | var buf [1 << 10]byte
20 | n, err := conn.Read(buf[:])
21 | if err != nil {
22 | return err
23 | }
24 | cmd := strings.TrimSpace(string(buf[:n]))
25 | cmdFn := localCommands[cmd]
26 | if cmdFn != nil {
27 | return cmdFn(conn)
28 | }
29 | _, err = io.WriteString(conn, "bad command\n")
30 | return err
31 | }
32 |
33 | // getSockPath gets a (hopefully unique) path for storing the smash socket.
34 | // (Note that the path doesn't need to be predictable across invocations,
35 | // as the socket path is passed to subcommands via the environment.)
36 | func getSockPath() (string, error) {
37 | path := os.Getenv("XDG_RUNTIME_DIR")
38 | if path == "" {
39 | var err error
40 | path, err = os.UserConfigDir()
41 | if err != nil {
42 | return "", err
43 | }
44 | }
45 | path = filepath.Join(path, "smash")
46 | if err := os.MkdirAll(path, 0700); err != nil {
47 | return "", err
48 | }
49 |
50 | sockName := fmt.Sprintf("sock.%d", os.Getpid())
51 | path = filepath.Join(path, sockName)
52 | if _, err := os.Stat(path); err != nil && !os.IsNotExist(err) {
53 | if err := os.Remove(path); err != nil {
54 | return "", err
55 | }
56 | }
57 |
58 | return path, nil
59 | }
60 |
61 | // deleteOnExit attempts to delete the given path when you ctl-c.
62 | func deleteOnExit(path string) {
63 | c := make(chan os.Signal)
64 | signal.Notify(c, os.Interrupt, syscall.SIGTERM)
65 | go func() {
66 | <-c
67 | os.Remove(path)
68 | os.Exit(128 + int(syscall.SIGTERM))
69 | }()
70 | }
71 |
72 | // setupLocalCommandSock creates the listening local command socket, and
73 | // returns its path and the socket.
74 | func setupLocalCommandSock() (string, net.Listener, error) {
75 | path, err := getSockPath()
76 | if err != nil {
77 | return "", nil, err
78 | }
79 | l, err := net.Listen("unix", path)
80 | if err != nil {
81 | deleteOnExit(path)
82 | }
83 | return path, l, err
84 | }
85 |
86 | // readLocalCommands loops forever, reading commands from the local socket.
87 | func readLocalCommands(sock net.Listener) error {
88 | defer sock.Close()
89 | for {
90 | conn, err := sock.Accept()
91 | if err != nil {
92 | return err
93 | }
94 | go func() {
95 | err := handleLocal(conn)
96 | if err != nil {
97 | fmt.Fprintf(os.Stderr, "local conn: %s\n", err)
98 | }
99 | }()
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/docs/related.md:
--------------------------------------------------------------------------------
1 | # Related work
2 |
3 | The purpose of smash is to explore new ideas in shells/terminals, and many
4 | others have explored these ideas as well. Over the years I've tinkered on smash
5 | I've also tried to collect the work of others as inspiration.
6 |
7 | I've attempted to break these into categories, but many of them span categories.
8 |
9 | ## Integration between shell and UI
10 |
11 | - [TermKit](https://acko.net/blog/on-termkit/) is the clearest ancestor of
12 | smash, attempting to rethink the UI of a terminal
13 | - [A Whole New World](https://www.destroyallsoftware.com/talks/a-whole-new-world)
14 | is a talk in part about a smarter terminal
15 | - [Upterm, formerly Black Screen](https://github.com/railsware/upterm),
16 | JS-based, lots of neat-looking ideas
17 | - [notty](https://github.com/withoutboats/notty), just a spec(?) for extended
18 | terminal escape codes
19 | - http://domterm.org/
20 | - http://extraterm.org/
21 |
22 | ## Better UI
23 |
24 | - [Terminology](https://www.enlightenment.org/about-terminology), X terminal
25 | emulator with stuff like inline image display
26 | - [iTerm2](https://www.iterm2.com/), OSX only, v3 does some clever stuff to
27 | integrate with the underlying shell
28 | - [Hyper](https://hyper.is/)
29 | - https://github.com/jwilm/alacritty
30 | - https://github.com/kovidgoyal/kitty
31 | - https://www.gnu.org/software/screen/screen.html
32 | - https://github.com/tmux/tmux
33 |
34 | ## Better shell
35 |
36 | - [Awkward](https://github.com/iostreamer-X/Awkward), nodejs as the shell
37 | - http://www.oilshell.org/
38 | - https://github.com/andrewchambers/janetsh
39 | - https://github.com/geophile/marcel
40 | - https://github.com/nushell/nushell
41 | - https://elvish.io/
42 | - https://github.com/ergonomica/ergonomica
43 | - https://fishshell.com/
44 | - https://en.wikipedia.org/wiki/PowerShell
45 |
46 | ## Better network
47 |
48 | - https://eternalterminal.dev/
49 |
50 | ## Uncategorized backlog
51 |
52 | - https://github.com/gnunn1/terminix
53 | - https://github.com/uobikiemukot/yaft
54 | - http://www.dmst.aueb.gr/dds/sw/dgsh/
55 | - https://github.com/stonewell/pymterm
56 | - https://github.com/ericfreese/rat
57 | - https://github.com/dundalek/closh
58 | - https://github.com/redox-os/ion
59 | - https://github.com/liamg/aminal
60 | - https://9fans.github.io/plan9port/man/man1/9term.html
61 | - https://github.com/liljencrantz/crush
62 | - https://www.marceltheshell.org/
63 | - https://github.com/iondodon/manter
64 | - https://github.com/lmorg/murex
65 | - https://huckridge.notion.site/Hucksh-overview-2fdcaf7d639145c0b192d0e19d7c25e4
66 | - https://terminal.click/
67 |
68 | ## Design
69 |
70 | - [Terminal benchmarking](https://danluu.com/term-latency)
71 | - [VSCode terminal renderer](https://code.visualstudio.com/blogs/2017/10/03/terminal-renderer)
72 | - [Hyperlinks in various terminal emulators](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)
73 |
--------------------------------------------------------------------------------
/web/src/readline_test.ts:
--------------------------------------------------------------------------------
1 | import * as readline from './readline';
2 | import { expect } from 'chai';
3 | import { cursorTo } from 'readline';
4 |
5 | function cursor(text: string): [string, number] {
6 | const pos = text.indexOf('|');
7 | if (pos === -1) return [text, 0];
8 | return [text.substring(0, pos) + text.substring(pos + 1), pos];
9 | }
10 |
11 | class Fake implements readline.InputHandler {
12 | text = '';
13 | pos = 0;
14 | history = 0;
15 |
16 | onEnter() {}
17 | tabComplete(state: {}): void {}
18 | setText(text: string): void {
19 | this.text = text;
20 | }
21 | setPos(pos: number): void {
22 | this.pos = pos;
23 | }
24 | showHistory(delta: -1 | 0 | 1): void {
25 | this.history = delta;
26 | }
27 |
28 | set(state: string) {
29 | [this.text, this.pos] = cursor(state);
30 | }
31 |
32 | interpret(key: string) {
33 | readline.interpretKey(
34 | { text: this.text, start: this.pos, end: this.pos },
35 | key,
36 | this
37 | );
38 | }
39 | expect(newState: string) {
40 | const [etext, epos] = cursor(newState);
41 | expect(this.text).equal(etext);
42 | expect(this.pos).equal(epos);
43 | }
44 | }
45 |
46 | describe('readline', () => {
47 | describe('word boundaries', () => {
48 | it('backward', () => {
49 | function expectBack(from: string, to: string) {
50 | const [text, pos1] = cursor(from);
51 | const [, pos2] = cursor(to);
52 | expect(readline.backwardWordBoundary(text, pos1)).equal(pos2);
53 | }
54 | expectBack('', '');
55 | expectBack('a|b', '|ab');
56 | expectBack('ab cd|', 'ab |cd');
57 | });
58 | });
59 |
60 | describe('interpretKey', () => {
61 | it('basic movement', () => {
62 | const fake = new Fake();
63 | fake.set('hello world|');
64 | fake.interpret('C-b');
65 | fake.expect('hello worl|d');
66 | fake.interpret('M-b');
67 | fake.expect('hello |world');
68 | fake.interpret('C-a');
69 | fake.expect('|hello world');
70 | fake.interpret('M-f');
71 | fake.expect('hello |world');
72 | fake.interpret('C-e');
73 | fake.expect('hello world|');
74 | });
75 |
76 | it('history', () => {
77 | const fake = new Fake();
78 | fake.interpret('C-p');
79 | expect(fake.history).equal(1);
80 |
81 | // Hitting C-c shouldn't reset history state.
82 | fake.interpret('C-c');
83 | expect(fake.history).equal(1);
84 |
85 | // Normal typing should reset history state.
86 | fake.interpret('c');
87 | expect(fake.history).equal(0);
88 |
89 | // Also arrow keys.
90 | fake.interpret('ArrowUp');
91 | expect(fake.history).equal(1);
92 | fake.interpret('ArrowDown');
93 | expect(fake.history).equal(-1);
94 |
95 | // Home/end don't reset history.
96 | fake.interpret('Home');
97 | expect(fake.history).equal(-1);
98 | fake.interpret('End');
99 | expect(fake.history).equal(-1);
100 | });
101 | });
102 | });
103 |
--------------------------------------------------------------------------------
/web/src/shell.ts:
--------------------------------------------------------------------------------
1 | import { AliasMap } from './alias';
2 | import * as path from './path';
3 |
4 | export function parseCmd(cmd: string): string[] {
5 | const parts = cmd.trim().split(/\s+/);
6 | if (parts.length === 1 && parts[0] === '') return [];
7 | return parts;
8 | }
9 |
10 | export interface ExecRemote {
11 | kind: 'remote';
12 | cwd: string;
13 | cmd: string[];
14 | onComplete?: (exitCode: number) => void;
15 | }
16 |
17 | export interface TableOutput {
18 | kind: 'table';
19 | headers: string[];
20 | rows: string[][];
21 | }
22 |
23 | export interface StringOutput {
24 | kind: 'string';
25 | output: string;
26 | }
27 |
28 | export type ExecOutput = ExecRemote | TableOutput | StringOutput;
29 |
30 | function strOutput(msg: string): ExecOutput {
31 | return { kind: 'string', output: msg };
32 | }
33 |
34 | export class Shell {
35 | aliases = new AliasMap();
36 | cwd = '/';
37 |
38 | constructor(public env = new Map()) {}
39 |
40 | init() {
41 | this.cwd = this.env.get('HOME') || '/';
42 | this.aliases.set('that', `${this.env.get('SMASH')} that`);
43 | }
44 |
45 | cwdForPrompt() {
46 | let cwd = this.cwd;
47 | const home = this.env.get('HOME');
48 | if (home && cwd.startsWith(home)) {
49 | cwd = '~' + cwd.substring(home.length);
50 | }
51 | return cwd;
52 | }
53 |
54 | builtinCd(argv: string[]): ExecOutput {
55 | if (argv.length > 1) {
56 | return strOutput('usage: cd [DIR]');
57 | }
58 | let arg = argv[0];
59 | if (!arg) {
60 | arg = this.env.get('HOME') || '/';
61 | }
62 | if (!arg.startsWith('/')) {
63 | arg = path.join(this.cwd, arg);
64 | }
65 | arg = path.normalize(arg);
66 | if (arg.length > 1 && arg.endsWith('/')) {
67 | arg = arg.substring(0, arg.length - 1);
68 | }
69 | return {
70 | kind: 'remote',
71 | cwd: this.cwd,
72 | cmd: ['cd', arg],
73 | onComplete: (exitCode: number) => {
74 | if (exitCode === 0) {
75 | this.cwd = arg;
76 | }
77 | },
78 | };
79 | }
80 |
81 | private handleBuiltin(argv: string[]): ExecOutput | undefined {
82 | switch (argv[0]) {
83 | case 'alias':
84 | if (argv.length > 2) {
85 | return strOutput('usage: alias [CMD]');
86 | }
87 | if (argv.length > 1) {
88 | return strOutput('TODO: alias CMD');
89 | }
90 | return {
91 | kind: 'table',
92 | headers: ['alias', 'expansion'],
93 | rows: Array.from(this.aliases.aliases),
94 | };
95 | case 'cd':
96 | return this.builtinCd(argv.slice(1));
97 | case 'env':
98 | if (argv.length > 1) return;
99 | return {
100 | kind: 'table',
101 | headers: ['var', 'value'],
102 | rows: Array.from(this.env),
103 | };
104 | }
105 | }
106 |
107 | exec(cmd: string): ExecOutput {
108 | cmd = cmd.trim();
109 | cmd = this.aliases.expand(cmd);
110 | const argv = parseCmd(cmd);
111 | const out = this.handleBuiltin(argv);
112 | if (out) return out;
113 | return { kind: 'remote', cwd: this.cwd, cmd: ['/bin/sh', '-c', cmd] };
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/web/src/connection.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * WebSocket connection setup.
3 | */
4 |
5 | import * as proto from './proto';
6 |
7 | const TRACE_MESSAGES = true;
8 |
9 | /** Prints a proto-encoded message. */
10 | function printMessage(prefix: string, msg: any) {
11 | if ('tag' in msg) {
12 | printMessage(`${prefix}tag(${msg.tag}):`, msg.val);
13 | return;
14 | }
15 | console.groupCollapsed(`${prefix}`);
16 | for (const field in msg) {
17 | const val = msg[field];
18 | if (typeof val === 'object' && !Array.isArray(val)) {
19 | printMessage(`${field}: `, val);
20 | } else {
21 | console.info(`${field}:`, val);
22 | }
23 | }
24 | console.groupEnd();
25 | }
26 |
27 | /** Parses a WebSocket MessageEvent as a server-sent message. */
28 | function parseMessage(event: MessageEvent): proto.ServerMsg {
29 | return new proto.Reader(new DataView(event.data)).readServerMsg();
30 | }
31 |
32 | /** Promisifies WebSocket connection. */
33 | function connect(ws: WebSocket): Promise {
34 | return new Promise((res, rej) => {
35 | ws.onopen = () => {
36 | res();
37 | };
38 | ws.onerror = () => {
39 | // Note: it's intentional for WebSocket security reasons that you cannot
40 | // get much information out of a connection failure, so ignore any data
41 | // passe in to onerror().
42 | rej(`websocket connection failed`);
43 | };
44 | });
45 | }
46 |
47 | async function read(ws: WebSocket): Promise {
48 | return new Promise((res, rej) => {
49 | ws.onmessage = (event) => {
50 | res(parseMessage(event));
51 | };
52 | ws.onclose = (event) => {
53 | let msg = 'connection closed';
54 | if (event.reason) msg += `: ${event.reason}`;
55 | rej(msg);
56 | };
57 | ws.onerror = () => {
58 | // Note: it's intentional for WebSocket security reasons that you cannot
59 | // get much information out of a connection failure, so ignore any data
60 | // passe in to onerror().
61 | rej(`websocket connection failed`);
62 | };
63 | });
64 | }
65 |
66 | export class ServerConnection {
67 | ws!: WebSocket;
68 |
69 | /**
70 | * Opens the connection to the server.
71 | */
72 | async connect(): Promise {
73 | const url = new URL('/ws', window.location.href);
74 | url.protocol = url.protocol.replace('http', 'ws');
75 | const ws = new WebSocket(url.href);
76 | ws.binaryType = 'arraybuffer';
77 | await connect(ws);
78 | ws.onopen = (event) => {
79 | console.error(`unexpected ws open:`, event);
80 | };
81 | this.ws = ws;
82 |
83 | const msg = await read(ws);
84 | if (msg.tag !== 'Hello') {
85 | throw new Error(`expected hello message, got ${msg}`);
86 | }
87 | return msg.val;
88 | }
89 |
90 | async read(): Promise {
91 | const msg = await read(this.ws);
92 | if (TRACE_MESSAGES) printMessage('read: ', msg);
93 | return msg;
94 | }
95 |
96 | send(msg: proto.ClientMessage) {
97 | if (TRACE_MESSAGES) printMessage('send: ', msg);
98 |
99 | // Write once with an empty buffer to measure, then a second time after
100 | // creating the buffer.
101 | const writer = new proto.Writer();
102 | writer.writeClientMessage(msg);
103 | writer.buf = new Uint8Array(writer.ofs);
104 | writer.ofs = 0;
105 | writer.writeClientMessage(msg);
106 | this.ws.send(writer.buf);
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/web/dist/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | width: 100%;
3 | height: 100%;
4 |
5 | display: flex;
6 | flex-direction: column;
7 | }
8 | body {
9 | font-family: 'segoe ui', 'roboto', sans-serif;
10 | font-size: 15px;
11 | margin: 0;
12 | flex: 1;
13 | min-height: 0;
14 |
15 | display: flex;
16 | flex-direction: column;
17 | }
18 |
19 | pre {
20 | font-family: WebKitWorkaround, monospace;
21 | margin: 0;
22 | outline: 0;
23 | }
24 | button {
25 | font: inherit;
26 | }
27 | table .value {
28 | word-break: break-all;
29 | }
30 | .error-popup {
31 | display: flex;
32 | align-items: baseline;
33 | border: solid 1px #f77;
34 | background: #fee;
35 | padding: 1ex 1.5ex;
36 | position: fixed;
37 | left: 4ex;
38 | bottom: 2ex;
39 | }
40 |
41 | .tabs {
42 | flex: 1;
43 | min-height: 0;
44 |
45 | display: flex;
46 | flex-direction: column;
47 | }
48 |
49 | .tabstrip {
50 | display: flex;
51 | }
52 |
53 | .tab {
54 | cursor: default;
55 | user-select: none;
56 | min-width: 20ex;
57 | padding: 4px;
58 | background: white;
59 | border-right: solid 1px #777;
60 | }
61 |
62 | .readline {
63 | display: flex;
64 | align-items: baseline;
65 | padding: 2px 1px;
66 | }
67 | .readline:focus-within {
68 | background: #eee;
69 | }
70 | .readline input {
71 | font: inherit;
72 | font-weight: bold;
73 | flex: 1;
74 | border: 0;
75 | outline: none;
76 | background: transparent;
77 | }
78 |
79 | .prompt {
80 | white-space: pre;
81 | cursor: pointer;
82 | }
83 | .input-box {
84 | flex: 1;
85 | position: relative;
86 | display: flex;
87 | }
88 | .popup {
89 | position: absolute;
90 | background: white;
91 | box-shadow: 1px 1px 2px 1px #aaa;
92 | padding: 2px 2px;
93 | }
94 | .popup > .completion {
95 | cursor: pointer;
96 | padding: 0 2px;
97 | }
98 | .popup > .completion.selected {
99 | background: #eee;
100 | }
101 |
102 | .measure {
103 | position: absolute;
104 | visibility: hidden;
105 | }
106 |
107 | .cellstack {
108 | flex: 1;
109 | box-shadow: 0 -1px 2px #777;
110 | padding: 4px;
111 | overflow-y: auto;
112 | }
113 |
114 | .bright {
115 | font-weight: bold;
116 | }
117 |
118 | .fg1 {
119 | color: #2e3436;
120 | }
121 | .fg2 {
122 | color: #cc0000;
123 | }
124 | .fg3 {
125 | color: #4e9a06;
126 | }
127 | .fg4 {
128 | color: #c4a000;
129 | }
130 | .fg5 {
131 | color: #3465a4;
132 | }
133 | .fg6 {
134 | color: #75507b;
135 | }
136 | .fg7 {
137 | color: #06989a;
138 | }
139 | .fg8 {
140 | color: #d3d7cf;
141 | }
142 |
143 | .bright.fg1 {
144 | color: #555753;
145 | }
146 | .bright.fg2 {
147 | color: #ef2929;
148 | }
149 | .bright.fg3 {
150 | color: #8ae234;
151 | }
152 | .bright.fg4 {
153 | color: #fce94f;
154 | }
155 | .bright.fg5 {
156 | color: #729fcf;
157 | }
158 | .bright.fg6 {
159 | color: #ad7fa8;
160 | }
161 | .bright.fg7 {
162 | color: #34e2e2;
163 | }
164 | .bright.fg8 {
165 | color: #eeeeec;
166 | }
167 |
168 | .bg1 {
169 | background: #2e3436;
170 | }
171 | .bg2 {
172 | background: #cc0000;
173 | }
174 | .bg3 {
175 | background: #4e9a06;
176 | }
177 | .bg4 {
178 | background: #c4a000;
179 | }
180 | .bg5 {
181 | background: #3465a4;
182 | }
183 | .bg6 {
184 | background: #75507b;
185 | }
186 | .bg7 {
187 | background: #06989a;
188 | }
189 | .bg8 {
190 | background: #d3d7cf;
191 | }
192 |
193 | .term {
194 | position: relative;
195 | overflow: hidden; /* hide offscreen cursor */
196 | }
197 | .term-cursor {
198 | position: absolute;
199 | background: rgba(255, 0, 0, 0.3);
200 | }
201 |
--------------------------------------------------------------------------------
/web/src/term.ts:
--------------------------------------------------------------------------------
1 | import { html, htext } from './html';
2 | import * as proto from './proto';
3 | import { translateKey } from './readline';
4 |
5 | interface Attr {
6 | fg: number;
7 | bg: number;
8 | bright: boolean;
9 | }
10 |
11 | /** Decodes a packed attribute number as described in terminal.go. */
12 | function decodeAttr(attr: number): Attr {
13 | const fg = attr & 0b1111;
14 | const bg = (attr & 0b11110000) >> 4;
15 | const bright = (attr & 0x0100) !== 0;
16 | return { fg, bg, bright };
17 | }
18 |
19 | const termKeyMap: { [key: string]: string } = {
20 | ArrowUp: '\x1b[A',
21 | ArrowDown: '\x1b[B',
22 | ArrowRight: '\x1b[C',
23 | ArrowLeft: '\x1b[D',
24 |
25 | Backspace: '\x08',
26 | Tab: '\x09',
27 | Enter: '\x0d',
28 | 'C-[': '\x1b',
29 | Escape: '\x1b',
30 | };
31 |
32 | /**
33 | * Client side DOM of terminal emulation.
34 | *
35 | * The actual vt100 etc. emulation happens on the server.
36 | * This client receives screen updates and forwards keystrokes.
37 | */
38 | export class Term {
39 | dom = html('pre', { tabIndex: 0, className: 'term' });
40 | cursor = html('div', { className: 'term-cursor' });
41 | cellSize = { width: 0, height: 0 };
42 |
43 | delegates = {
44 | /** Sends a keyboard event to the terminal's subprocess. */
45 | key: (msg: proto.KeyEvent) => {},
46 | };
47 |
48 | constructor() {
49 | this.dom.onkeydown = (e) => this.onKeyDown(e);
50 | this.dom.onkeypress = (e) => this.onKeyPress(e);
51 | this.dom.appendChild(this.cursor);
52 | this.measure();
53 | // Create initial empty line, for height.
54 | // This will be replaced as soon as an update comes in.
55 | this.dom.appendChild(html('div', {}, htext(' ')));
56 | }
57 |
58 | measure() {
59 | document.body.appendChild(this.dom);
60 | this.cursor.innerText = 'A';
61 | const { width, height } = getComputedStyle(this.cursor);
62 | document.body.removeChild(this.dom);
63 | this.cursor.innerText = '';
64 | this.cursor.style.width = width;
65 | this.cursor.style.height = height;
66 | this.cellSize.width = Number(width!.replace('px', ''));
67 | this.cellSize.height = Number(height!.replace('px', ''));
68 | }
69 |
70 | focus() {
71 | this.dom.focus();
72 | }
73 |
74 | preventFocus() {
75 | this.dom.removeAttribute('tabindex');
76 | }
77 |
78 | onUpdate(msg: proto.TermUpdate) {
79 | let childIdx = 0;
80 | let child = this.dom.children[1] as HTMLElement; // avoid this.cursor
81 | for (const rowSpans of msg.rows) {
82 | const row = rowSpans.row;
83 | for (; childIdx < row; childIdx++) {
84 | if (!child.nextSibling) {
85 | this.dom.appendChild(html('div', {}, htext(' ')));
86 | }
87 | child = child.nextSibling! as HTMLElement;
88 | }
89 | const spans = rowSpans.spans;
90 | if (spans.length === 0) {
91 | // Empty line. Set text to something non-empty so the div isn't
92 | // collapsed.
93 | child.innerText = ' ';
94 | } else {
95 | child.innerText = '';
96 | for (const span of spans) {
97 | const { fg, bg, bright } = decodeAttr(span.attr);
98 | const hspan = html('span');
99 | if (bright) hspan.classList.add(`bright`);
100 | if (fg > 0) hspan.classList.add(`fg${fg}`);
101 | if (bg > 0) hspan.classList.add(`bg${bg}`);
102 | hspan.innerText = span.text;
103 | child.appendChild(hspan);
104 | }
105 | }
106 | }
107 | const cursor = msg.cursor;
108 | if (cursor) {
109 | this.showCursor(!cursor.hidden);
110 | this.cursor.style.left = cursor.col * this.cellSize.width + 'px';
111 | this.cursor.style.top = cursor.row * this.cellSize.height + 'px';
112 | }
113 | while (this.dom.childElementCount > msg.rowCount + 1) {
114 | this.dom.removeChild(this.dom.lastChild!);
115 | }
116 | }
117 |
118 | showCursor(show: boolean) {
119 | this.cursor.style.display = show ? 'block' : 'none';
120 | }
121 |
122 | sendKeys(keys: string) {
123 | return this.delegates.key({ cell: 0, keys });
124 | }
125 |
126 | onKeyDown(ev: KeyboardEvent) {
127 | let send: string | undefined;
128 | if (!ev.altKey && !ev.metaKey && ev.ctrlKey && ev.key.length === 1) {
129 | const code = ev.key.charCodeAt(0) - 'a'.charCodeAt(0);
130 | if (code >= 0 && code < 26) {
131 | // Control+letter => send the letter as a 0-based byte.
132 | send = String.fromCharCode(code + 1);
133 | }
134 | }
135 | if (!send) {
136 | send = termKeyMap[translateKey(ev)];
137 | }
138 | if (!send) return;
139 | this.sendKeys(send);
140 | ev.preventDefault();
141 | }
142 |
143 | onKeyPress(ev: KeyboardEvent) {
144 | if (ev.key.length !== 1) {
145 | console.log('long press', ev.key);
146 | return;
147 | }
148 | this.sendKeys(ev.key);
149 | ev.preventDefault();
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/cli/bash/complete.go:
--------------------------------------------------------------------------------
1 | // Package bash wraps a bash subprocess, reusing its tab completion support.
2 | package bash
3 |
4 | import (
5 | "bufio"
6 | "fmt"
7 | "io"
8 | "io/ioutil"
9 | "os"
10 | "os/exec"
11 | "syscall"
12 | "unsafe"
13 |
14 | "github.com/kr/pty"
15 | )
16 |
17 | type winsize struct {
18 | row, col uint16
19 | xpix, ypix uint16
20 | }
21 |
22 | type Bash struct {
23 | cmd *exec.Cmd
24 | pty *os.File
25 | lines *bufio.Scanner
26 | }
27 |
28 | // Magic string indicating a ready prompt.
29 | const promptMagic = "***READY>"
30 |
31 | // inputrc file contents, used to
32 | // - make completion always display
33 | // - disable the pager
34 | // - disable bracketed paste (which causes special xterm escapes in the output)
35 | const inputrc = `set completion-query-items 0
36 | set page-completions off
37 | set enable-bracketed-paste off
38 | `
39 |
40 | // StartBash starts up a new bash subprocess for use in completions.
41 | func StartBash() (b *Bash, err error) {
42 | f, err := ioutil.TempFile("", "smash-inputrc")
43 | if err != nil {
44 | return nil, err
45 | }
46 | io.WriteString(f, inputrc)
47 | f.Close()
48 | defer os.Remove(f.Name())
49 |
50 | b = &Bash{}
51 | b.cmd = exec.Command("bash", "-i")
52 | b.cmd.Env = append(b.cmd.Env, fmt.Sprintf("INPUTRC=%s", f.Name()))
53 | if b.pty, err = pty.Start(b.cmd); err != nil {
54 | return nil, err
55 | }
56 | b.lines = bufio.NewScanner(b.pty)
57 | if err = b.disableEcho(); err != nil {
58 | return nil, err
59 | }
60 | if err = b.setNarrow(); err != nil {
61 | return nil, err
62 | }
63 | if err = b.setupPrompt(); err != nil {
64 | return nil, err
65 | }
66 | return
67 | }
68 |
69 | // setupPrompt adjusts the prompt to the magic string, which makes it easy for
70 | // us to identify when the prompt is ready.
71 | func (b *Bash) setupPrompt() error {
72 | fmt.Fprintf(b.pty, "export PS1='\n%s\n'\n", promptMagic)
73 | for b.lines.Scan() {
74 | // log.Printf("setup read %q", b.lines.Text())
75 | if b.lines.Text() == promptMagic {
76 | break
77 | }
78 | }
79 | return b.lines.Err()
80 | }
81 |
82 | // disableEcho disables terminal echoing, which simplifies parsing by
83 | // not having our inputs mixed into it.
84 | func (b *Bash) disableEcho() error {
85 | var termios syscall.Termios
86 | _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
87 | b.pty.Fd(), uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&termios)))
88 | if errno != 0 {
89 | return errno
90 | }
91 | termios.Lflag &^= syscall.ECHO
92 | _, _, errno = syscall.Syscall(syscall.SYS_IOCTL,
93 | b.pty.Fd(), uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(&termios)))
94 | if errno != 0 {
95 | return errno
96 | }
97 | return nil
98 | }
99 |
100 | // setNarrow sets the pty to be very narrow, causing bash to print each
101 | // completion on its own line.
102 | func (b *Bash) setNarrow() error {
103 | ws := winsize{
104 | col: 2, // Note: 1 doesn't seem to have an effect.
105 | }
106 | _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
107 | uintptr(b.pty.Fd()), uintptr(syscall.TIOCSWINSZ),
108 | uintptr(unsafe.Pointer(&ws)))
109 | if errno != 0 {
110 | return errno
111 | }
112 | return nil
113 | }
114 |
115 | func (b *Bash) Chdir(path string) error {
116 | fmt.Fprintf(b.pty, "cd %q\n", path)
117 | // Expected output is newline + a new prompt.
118 | b.lines.Scan()
119 | if line := b.lines.Text(); line != "" {
120 | return fmt.Errorf("bash: unexpected line %q", line)
121 | }
122 | b.lines.Scan()
123 | if line := b.lines.Text(); line != promptMagic {
124 | return fmt.Errorf("bash: unexpected line %q", line)
125 | }
126 | return nil
127 | }
128 |
129 | // expand passes some input through the bash subprocess to gather
130 | // potential expansions.
131 | func (b *Bash) expand(input string) (expansions []string, err error) {
132 | // Write:
133 | // - the input
134 | // - M-? ("expand")
135 | // - C-u ("clear text")
136 | // - \n (to print a newline at the end)
137 | fmt.Fprintf(b.pty, "%s\x1b?\x15\n", input)
138 |
139 | // If there are any completions, bash prints:
140 | // 1) one newline (bash clearing to next line to print completions)
141 | // 2) list of files
142 | // Regardless of completions or not, it's terminated by the prompt
143 | // (empty line and prompt). This means it's ambiguous if the
144 | // first completion matches the magic prompt string. :(
145 | sawNL := false
146 | L:
147 | for b.lines.Scan() {
148 | line := b.lines.Text()
149 | // log.Printf("line %q", line)
150 | switch {
151 | case line == "":
152 | sawNL = true
153 | case sawNL && line == promptMagic:
154 | break L
155 | default:
156 | sawNL = false
157 | expansions = append(expansions, line)
158 | }
159 | }
160 | err = b.lines.Err()
161 | return
162 | }
163 |
164 | func (b *Bash) Complete(input string) (int, []string, error) {
165 | expansions, err := b.expand(input)
166 | if err != nil {
167 | return 0, nil, err
168 | }
169 | if len(expansions) == 0 {
170 | return 0, nil, nil
171 | }
172 |
173 | // We've got some completions, but we need to guess at the correct
174 | // offset. Some cases to consider:
175 | // "ls " => "foo", "bar", "baz"
176 | // "ls b" => "bar", "baz"
177 | // "ls ./b" => "bar", "baz"
178 | // "ls ./*" => "foo", "bar", "baz"
179 | // "ls --c" => "--classify", "--color=", ...
180 | // Current logic: back up until we hit a space or a slash.
181 | var ofs int
182 | for ofs = len(input); ofs > 0; ofs-- {
183 | if input[ofs-1] == ' ' || input[ofs-1] == '/' {
184 | break
185 | }
186 | }
187 |
188 | return ofs, expansions, err
189 | }
190 |
--------------------------------------------------------------------------------
/web/src/cells.ts:
--------------------------------------------------------------------------------
1 | import { History } from './history';
2 | import { htext, html } from './html';
3 | import * as proto from './proto';
4 | import * as readline from './readline';
5 | import { ReadLine } from './readline';
6 | import * as sh from './shell';
7 | import { Shell } from './shell';
8 | import { Term } from './term';
9 |
10 | const history = new History();
11 |
12 | interface PendingComplete {
13 | id: number;
14 | resolve: (resp: readline.CompleteResponse) => void;
15 | reject: () => void;
16 | }
17 |
18 | class Cell {
19 | dom = html('div', { className: 'cell' });
20 | readline = new ReadLine(history);
21 | term = new Term();
22 | /** Did the subprocess produce any output? */
23 | didOutput = false;
24 | running: sh.ExecRemote | null = null;
25 |
26 | delegates = {
27 | /** Called when the subprocess exits. */
28 | exit: (id: number, exitCode: number) => {},
29 |
30 | /** Sends a server message. */
31 | send: (msg: proto.ClientMessage) => {},
32 | };
33 |
34 | pendingComplete?: PendingComplete;
35 |
36 | constructor(readonly id: number, readonly shell: Shell) {
37 | this.dom.appendChild(this.readline.dom);
38 | this.term.delegates = {
39 | key: (key) => {
40 | key.cell = this.id;
41 | this.delegates.send({ tag: 'KeyEvent', val: key });
42 | },
43 | };
44 |
45 | this.readline.delegates = {
46 | oncomplete: async (req) => {
47 | return new Promise((resolve, reject) => {
48 | const reqProto: proto.CompleteRequest = {
49 | id: 0,
50 | cwd: shell.cwd,
51 | input: req.input,
52 | pos: req.pos,
53 | };
54 | const msg: proto.ClientMessage = {
55 | tag: 'CompleteRequest',
56 | val: reqProto,
57 | };
58 | this.delegates.send(msg);
59 | this.pendingComplete = {
60 | id: 0,
61 | resolve,
62 | reject,
63 | };
64 | });
65 | },
66 |
67 | oncommit: (cmd) => {
68 | const exec = shell.exec(cmd);
69 | switch (exec.kind) {
70 | case 'string':
71 | this.term.dom.innerText = exec.output;
72 | break;
73 | case 'table':
74 | this.term.dom = this.renderTable(exec);
75 | break;
76 | case 'remote':
77 | this.running = exec;
78 | this.spawn(this.id, exec);
79 | // The result of spawning will come back in via a message in onOutput().
80 | break;
81 | }
82 | this.dom.appendChild(this.term.dom);
83 | this.term.dom.focus();
84 | if (!this.running) {
85 | this.delegates.exit(this.id, 0);
86 | }
87 | },
88 | };
89 | }
90 |
91 | private renderTable(exec: sh.TableOutput) {
92 | return html(
93 | 'table',
94 | {},
95 | html('tr', {}, ...exec.headers.map((h) => html('th', {}, htext(h)))),
96 | ...exec.rows.map((r) =>
97 | html(
98 | 'tr',
99 | {},
100 | ...r.map((t, i) =>
101 | html('td', { className: i > 0 ? 'value' : '' }, htext(t))
102 | )
103 | )
104 | )
105 | );
106 | }
107 |
108 | spawn(id: number, cmd: sh.ExecRemote) {
109 | const run: proto.RunRequest = {
110 | cell: id,
111 | cwd: cmd.cwd,
112 | argv: cmd.cmd,
113 | };
114 | this.delegates.send({ tag: 'RunRequest', val: run });
115 | }
116 |
117 | onOutput(msg: proto.Output) {
118 | switch (msg.tag) {
119 | case 'CmdError':
120 | // error; exit code will come later.
121 | this.dom.appendChild(html('div', {}, htext(msg.val.error)));
122 | break;
123 | case 'TermUpdate':
124 | this.didOutput = true;
125 | this.term.onUpdate(msg.val);
126 | break;
127 | case 'Exit':
128 | // exit code
129 | // Command completed.
130 | const exitCode = msg.val.exitCode;
131 | if (this.running && this.running.onComplete) {
132 | this.running.onComplete(exitCode);
133 | }
134 | this.running = null;
135 | this.term.showCursor(false);
136 | this.term.preventFocus();
137 | if (!this.didOutput) {
138 | // Remove the vertical space of the terminal.
139 | this.term.dom.innerText = '';
140 | }
141 | this.delegates.exit(this.id, exitCode);
142 | }
143 | }
144 |
145 | onCompleteResponse(msg: proto.CompleteResponse) {
146 | if (!this.pendingComplete) return;
147 | this.pendingComplete.resolve({
148 | completions: msg.completions,
149 | pos: msg.pos,
150 | });
151 | this.pendingComplete = undefined;
152 | }
153 |
154 | focus() {
155 | if (this.running) {
156 | this.term.focus();
157 | } else {
158 | this.readline.focus();
159 | }
160 | }
161 | }
162 |
163 | function scrollToBottom(el: HTMLElement) {
164 | el.scrollIntoView({
165 | block: 'end',
166 | });
167 | }
168 |
169 | export class CellStack {
170 | dom = html('div', { className: 'cellstack' });
171 | cells: Cell[] = [];
172 | delegates = {
173 | send: (msg: proto.ClientMessage) => {},
174 | };
175 |
176 | constructor(readonly shell: Shell) {
177 | this.addNew();
178 | }
179 |
180 | addNew() {
181 | const id = this.cells.length;
182 | const cell = new Cell(id, this.shell);
183 | cell.readline.setPrompt(this.shell.cwdForPrompt());
184 | cell.delegates = {
185 | send: (msg) => this.delegates.send(msg),
186 | exit: (id: number, exitCode: number) => {
187 | this.onExit(id, exitCode);
188 | },
189 | };
190 | this.cells.push(cell);
191 | this.dom.appendChild(cell.dom);
192 | cell.readline.input.focus();
193 | scrollToBottom(cell.dom);
194 | }
195 |
196 | onOutput(msg: proto.CellOutput) {
197 | const cell = this.cells[msg.cell];
198 | cell.onOutput(msg.output);
199 | if (msg.cell === this.cells.length - 1) {
200 | scrollToBottom(cell.dom);
201 | }
202 | }
203 |
204 | onExit(id: number, exitCode: number) {
205 | this.addNew();
206 | }
207 |
208 | getLastCell(): Cell {
209 | return this.cells[this.cells.length - 1];
210 | }
211 |
212 | focus() {
213 | this.getLastCell().focus();
214 | }
215 | }
216 |
--------------------------------------------------------------------------------
/cli/vt100/terminal_test.go:
--------------------------------------------------------------------------------
1 | package vt100
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "fmt"
7 | "io"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/stretchr/testify/assert"
12 | )
13 |
14 | func makeInput(input string) *bufio.Reader {
15 | buf := strings.NewReader(input)
16 | return bufio.NewReader(buf)
17 | }
18 |
19 | func newTestTerminal() (*Terminal, *TermReader) {
20 | term := NewTerminal()
21 | tr := NewTermReader(func(f func(t *Terminal)) {
22 | f(term)
23 | })
24 | return term, tr
25 | }
26 |
27 | func mustRun(t *testing.T, tr *TermReader, input string) {
28 | r := makeInput(input)
29 | var err error
30 | for err == nil {
31 | err = tr.Read(r)
32 | }
33 | assert.Equal(t, err, io.EOF)
34 | }
35 |
36 | func assertPos(t *testing.T, term *Terminal, row, col int) {
37 | assert.Equal(t, row, term.Row)
38 | assert.Equal(t, col, term.Col)
39 | }
40 |
41 | func TestBasic(t *testing.T) {
42 | term, tr := newTestTerminal()
43 | mustRun(t, tr, "test")
44 | assert.Equal(t, "test", term.ToString())
45 | mustRun(t, tr, "\nbar")
46 | assert.Equal(t, "test\nbar", term.ToString())
47 | mustRun(t, tr, "\rfoo")
48 | assert.Equal(t, "test\nfoo", term.ToString())
49 | mustRun(t, tr, "\n\n")
50 | assert.Equal(t, "test\nfoo\n\n", term.ToString())
51 | mustRun(t, tr, "x\ty")
52 | assert.Equal(t, "test\nfoo\n\nx y", term.ToString())
53 | }
54 |
55 | func TestTitle(t *testing.T) {
56 | term, tr := newTestTerminal()
57 | mustRun(t, tr, "\x1b]0;title\x07text")
58 | assert.Equal(t, "title", term.Title)
59 | assert.Equal(t, "text", term.ToString())
60 | }
61 |
62 | func TestReset(t *testing.T) {
63 | term, tr := newTestTerminal()
64 | tr.Attr = 43
65 | mustRun(t, tr, "\x1b[0m")
66 | assert.Equal(t, Attr(0), tr.Attr)
67 | assert.Equal(t, "", term.ToString())
68 | }
69 |
70 | func TestColor(t *testing.T) {
71 | term, tr := newTestTerminal()
72 | assert.Equal(t, false, tr.Attr.Bright())
73 | assert.Equal(t, false, tr.Attr.Inverse())
74 | assert.Equal(t, 0, tr.Attr.Color())
75 |
76 | mustRun(t, tr, "\x1b[1;34m") // set bright blue
77 | assert.Equal(t, true, tr.Attr.Bright())
78 | assert.Equal(t, 5, tr.Attr.Color())
79 | assert.Equal(t, "", term.ToString())
80 |
81 | mustRun(t, tr, "\x1b[7m") // set inverse
82 | assert.Equal(t, true, tr.Attr.Inverse())
83 |
84 | assert.Equal(t, true, tr.Attr.Bright())
85 | mustRun(t, tr, "\x1b[22m") // normal intensity
86 | assert.Equal(t, false, tr.Attr.Bright())
87 |
88 | mustRun(t, tr, "\x1b[2m") // "faint" intensity, not implemented
89 |
90 | mustRun(t, tr, "\x1b[m")
91 | assert.Equal(t, Attr(0), tr.Attr)
92 | }
93 |
94 | func TestBackspace(t *testing.T) {
95 | term, tr := newTestTerminal()
96 | mustRun(t, tr, "\x08")
97 | assert.Equal(t, "", term.ToString())
98 | mustRun(t, tr, "x\x08")
99 | assert.Equal(t, "x", term.ToString())
100 | mustRun(t, tr, "ab\x08c")
101 | assert.Equal(t, "ac", term.ToString())
102 | }
103 |
104 | func TestEraseLine(t *testing.T) {
105 | term, tr := newTestTerminal()
106 | mustRun(t, tr, "hello")
107 | term.Col -= 2
108 | mustRun(t, tr, "\x1b[K")
109 | assert.Equal(t, "hel", term.ToString())
110 | mustRun(t, tr, "\x1b[1K")
111 | assert.Equal(t, " ", term.ToString())
112 |
113 | mustRun(t, tr, "hello")
114 | term.Col -= 2
115 | mustRun(t, tr, "\x1b[2K")
116 | assert.Equal(t, "", term.ToString())
117 | }
118 |
119 | func TestEraseDisplay(t *testing.T) {
120 | term, tr := newTestTerminal()
121 | mustRun(t, tr, "hellofoo\b\b\b")
122 | mustRun(t, tr, "\x1b[J")
123 | assert.Equal(t, "hello", term.ToString())
124 | mustRun(t, tr, "\x1b[2J")
125 | assert.Equal(t, "", term.ToString())
126 | }
127 |
128 | func TestDelete(t *testing.T) {
129 | term, tr := newTestTerminal()
130 | mustRun(t, tr, "abcdef\x08\x08\x08\x1b[1P")
131 | assert.Equal(t, "abcef", term.ToString())
132 |
133 | // Check deleting past the end of the line.
134 | mustRun(t, tr, "\x1b[5P")
135 | assert.Equal(t, "abc", term.ToString())
136 | }
137 |
138 | func TestBell(t *testing.T) {
139 | term, tr := newTestTerminal()
140 | mustRun(t, tr, "\x07")
141 | // ignored
142 | assert.Equal(t, "", term.ToString())
143 | }
144 |
145 | func TestPrivateModes(t *testing.T) {
146 | term, tr := newTestTerminal()
147 | mustRun(t, tr, "\x1b[?1049h")
148 | // ignored
149 | assert.Equal(t, "", term.ToString())
150 |
151 | mustRun(t, tr, "\x1b[?7h")
152 | // ignored
153 | assert.Equal(t, "", term.ToString())
154 | }
155 |
156 | func TestScrollingRegion(t *testing.T) {
157 | term, tr := newTestTerminal()
158 | mustRun(t, tr, "\x1b[1;24r")
159 | // ignored
160 | assert.Equal(t, "", term.ToString())
161 | }
162 |
163 | func TestResetMode(t *testing.T) {
164 | term, tr := newTestTerminal()
165 | mustRun(t, tr, "\x1b[4l")
166 | // ignored
167 | assert.Equal(t, "", term.ToString())
168 | }
169 |
170 | func TestMoveTo(t *testing.T) {
171 | term, tr := newTestTerminal()
172 | mustRun(t, tr, "hello\x1b[HX")
173 | assert.Equal(t, "Xello", term.ToString())
174 | mustRun(t, tr, "\x1b[1;3HX")
175 | assert.Equal(t, "XeXlo", term.ToString())
176 | mustRun(t, tr, "\x1b[0;0HY")
177 | assert.Equal(t, "YeXlo", term.ToString())
178 | }
179 |
180 | func TestMoveToLine(t *testing.T) {
181 | term, tr := newTestTerminal()
182 | mustRun(t, tr, "hello\n\n\x1b[2dfoo")
183 | assert.Equal(t, "hello\nfoo\n", term.ToString())
184 | }
185 |
186 | func TestCursor(t *testing.T) {
187 | term, tr := newTestTerminal()
188 | mustRun(t, tr, "foo\nbar")
189 | assertPos(t, term, 1, 3)
190 |
191 | mustRun(t, tr, "\x1b[C")
192 | assertPos(t, term, 1, 4)
193 | mustRun(t, tr, "\x1b[2C")
194 | assertPos(t, term, 1, 6)
195 | assert.Equal(t, "foo\nbar ", term.ToString())
196 |
197 | mustRun(t, tr, "\x1b[A!")
198 | assertPos(t, term, 0, 7)
199 | assert.Equal(t, "foo !\nbar ", term.ToString())
200 |
201 | mustRun(t, tr, "\x1b[5D")
202 | assertPos(t, term, 0, 2)
203 | }
204 |
205 | func TestScrollUp(t *testing.T) {
206 | term, tr := newTestTerminal()
207 | mustRun(t, tr, "aaa\nbbb\nccc\n")
208 | assertPos(t, term, 3, 0)
209 | mustRun(t, tr, "\x1bMX")
210 | assert.Equal(t, "aaa\nbbb\nXcc\n", term.ToString())
211 | mustRun(t, tr, "\x1bMY")
212 | mustRun(t, tr, "\x1bMZ")
213 | assert.Equal(t, "aaZ\nbYb\nXcc\n", term.ToString())
214 | mustRun(t, tr, "\x1bM1")
215 | assert.Equal(t, " 1\naaZ\nbYb\nXcc\n", term.ToString())
216 | }
217 |
218 | func TestScrollUpDropLines(t *testing.T) {
219 | term, tr := newTestTerminal()
220 | term.Height = 3
221 | mustRun(t, tr, "aaa\nbbb\nccc\n")
222 | assert.Equal(t, "aaa\nbbb\nccc\n", term.ToString())
223 | mustRun(t, tr, "\x1bM\x1bM\x1bM\x1bMx")
224 | assert.Equal(t, "x\naaa\nbbb", term.ToString())
225 | }
226 |
227 | func TestWrap(t *testing.T) {
228 | term, tr := newTestTerminal()
229 | term.Width = 5
230 | mustRun(t, tr, "1234567890")
231 | assert.Equal(t, "12345\n67890", term.ToString())
232 | }
233 |
234 | func TestUTF8(t *testing.T) {
235 | term, tr := newTestTerminal()
236 | mustRun(t, tr, "\xe2\x96\xbd")
237 | assert.Equal(t, rune(0x25bd), term.Lines[0][0].Ch)
238 | }
239 |
240 | func TestStatusReport(t *testing.T) {
241 | term, tr := newTestTerminal()
242 | buf := &bytes.Buffer{}
243 | tr.Input = buf
244 | mustRun(t, tr, "\x1b[5n")
245 | assert.Equal(t, "", term.ToString())
246 | assert.Equal(t, "\x1b[0n", buf.String())
247 |
248 | buf.Reset()
249 | mustRun(t, tr, "\x1b[6n")
250 | assert.Equal(t, "\x1b[1;1R", buf.String())
251 | }
252 |
253 | func TestCSIDisableModifiers(t *testing.T) {
254 | term, tr := newTestTerminal()
255 | mustRun(t, tr, "\x1b[>0n")
256 | assert.Equal(t, "", term.ToString())
257 | // TODO: implement the disabling, whatever that is.
258 | }
259 |
260 | func TestSendDeviceAttributes(t *testing.T) {
261 | term, tr := newTestTerminal()
262 | buf := &bytes.Buffer{}
263 | tr.Input = buf
264 | mustRun(t, tr, "\x1b[c")
265 | assert.Equal(t, "", term.ToString())
266 | assert.Equal(t, "", buf.String())
267 | mustRun(t, tr, "\x1b[>c")
268 | assert.Equal(t, "", term.ToString())
269 | assert.Equal(t, "\x1b[0;0;0c", buf.String())
270 | }
271 |
272 | func TestHideCursor(t *testing.T) {
273 | term, tr := newTestTerminal()
274 | mustRun(t, tr, "\x1b[?25l")
275 | assert.Equal(t, true, term.HideCursor)
276 | mustRun(t, tr, "\x1b[?25h")
277 | assert.Equal(t, false, term.HideCursor)
278 | }
279 |
280 | func TestInsertBlanks(t *testing.T) {
281 | term, tr := newTestTerminal()
282 | mustRun(t, tr, "ABC\b\b\x1b[@x")
283 | assert.Equal(t, "AxBC", term.ToString())
284 | mustRun(t, tr, "\x1b[2@y")
285 | assert.Equal(t, "Axy BC", term.ToString())
286 | }
287 |
288 | func TestInsertLine(t *testing.T) {
289 | term, tr := newTestTerminal()
290 | mustRun(t, tr, "foo\nbar\nbaz\n")
291 | mustRun(t, tr, "\x1b[2A\x1b[L") // two lines up, insert line
292 | mustRun(t, tr, "\nX")
293 | assert.Equal(t, "foo\n\nXar\nbaz\n", term.ToString())
294 | }
295 |
296 | func TestBinary(t *testing.T) {
297 | term, tr := newTestTerminal()
298 | // Don't choke on non-UTF8 inputs.
299 | // TODO: maybe render them with some special character to represent
300 | // mojibake.
301 | mustRun(t, tr, "\xc8\x00\x64\x00")
302 | assert.Equal(t, "@@d@", term.ToString())
303 | }
304 |
305 | func TestAllColors(t *testing.T) {
306 | buf := &bytes.Buffer{}
307 | for i := 30; i < 50; i++ {
308 | fmt.Fprintf(buf, "\x1b[%dmx", i)
309 | }
310 | term, tr := newTestTerminal()
311 | mustRun(t, tr, buf.String())
312 | x20 := "xxxxxxxxxx" + "xxxxxxxxxx"
313 | assert.Equal(t, x20, term.ToString())
314 | assert.Nil(t, term.Validate())
315 | }
316 |
317 | func TestNoScrollback(t *testing.T) {
318 | term, tr := newTestTerminal()
319 | term.Height = 3
320 | term.CanScroll = false
321 | mustRun(t, tr, "a\nb\nc\n")
322 | // The third line caused the terminal to scroll, but due to no scrollback that means the
323 | // lines are lost.
324 | assert.Equal(t, term.Top, 0)
325 | assert.Equal(t, "b\nc\n", term.ToString())
326 | }
327 |
--------------------------------------------------------------------------------
/web/src/proto.ts:
--------------------------------------------------------------------------------
1 | export type uint8 = number;
2 | export type ClientMessage =
3 | | { tag: 'CompleteRequest'; val: CompleteRequest }
4 | | { tag: 'RunRequest'; val: RunRequest }
5 | | { tag: 'KeyEvent'; val: KeyEvent };
6 | export interface CompleteRequest {
7 | id: number;
8 | cwd: string;
9 | input: string;
10 | pos: number;
11 | }
12 | export interface CompleteResponse {
13 | id: number;
14 | error: string;
15 | pos: number;
16 | completions: string[];
17 | }
18 | export interface RunRequest {
19 | cell: number;
20 | cwd: string;
21 | argv: string[];
22 | }
23 | export interface KeyEvent {
24 | cell: number;
25 | keys: string;
26 | }
27 | export interface RowSpans {
28 | row: number;
29 | spans: Span[];
30 | }
31 | export interface Span {
32 | attr: number;
33 | text: string;
34 | }
35 | export interface Cursor {
36 | row: number;
37 | col: number;
38 | hidden: boolean;
39 | }
40 | export interface TermUpdate {
41 | rows: RowSpans[];
42 | cursor: Cursor;
43 | rowCount: number;
44 | }
45 | export interface Pair {
46 | key: string;
47 | val: string;
48 | }
49 | export interface Hello {
50 | alias: Pair[];
51 | env: Pair[];
52 | }
53 | export interface CmdError {
54 | error: string;
55 | }
56 | export interface Exit {
57 | exitCode: number;
58 | }
59 | export type Output =
60 | | { tag: 'CmdError'; val: CmdError }
61 | | { tag: 'TermUpdate'; val: TermUpdate }
62 | | { tag: 'Exit'; val: Exit };
63 | export interface CellOutput {
64 | cell: number;
65 | output: Output;
66 | }
67 | export type ServerMsg =
68 | | { tag: 'Hello'; val: Hello }
69 | | { tag: 'CompleteResponse'; val: CompleteResponse }
70 | | { tag: 'CellOutput'; val: CellOutput };
71 | export class Reader {
72 | private ofs = 0;
73 | constructor(readonly view: DataView) {}
74 |
75 | private readUint8(): number {
76 | return this.view.getUint8(this.ofs++);
77 | }
78 |
79 | private readInt(): number {
80 | let val = 0;
81 | let shift = 0;
82 | for (;;) {
83 | const b = this.readUint8();
84 | val |= (b & 0x7f) << shift;
85 | if ((b & 0x80) === 0) break;
86 | shift += 7;
87 | }
88 | return val;
89 | }
90 |
91 | private readBoolean(): boolean {
92 | return this.readUint8() !== 0;
93 | }
94 |
95 | private readBytes(): DataView {
96 | const len = this.readInt();
97 | const slice = new DataView(this.view.buffer, this.ofs, len);
98 | this.ofs += len;
99 | return slice;
100 | }
101 |
102 | private readString(): string {
103 | const bytes = this.readBytes();
104 | return new TextDecoder().decode(bytes);
105 | }
106 |
107 | private readArray(elem: () => T): T[] {
108 | const len = this.readInt();
109 | const arr: T[] = new Array(len);
110 | for (let i = 0; i < len; i++) {
111 | arr[i] = elem();
112 | }
113 | return arr;
114 | }
115 | readClientMessage(): ClientMessage {
116 | switch (this.readUint8()) {
117 | case 1:
118 | return { tag: 'CompleteRequest', val: this.readCompleteRequest() };
119 | case 2:
120 | return { tag: 'RunRequest', val: this.readRunRequest() };
121 | case 3:
122 | return { tag: 'KeyEvent', val: this.readKeyEvent() };
123 | default:
124 | throw new Error('parse error');
125 | }
126 | }
127 | readCompleteRequest(): CompleteRequest {
128 | return {
129 | id: this.readInt(),
130 | cwd: this.readString(),
131 | input: this.readString(),
132 | pos: this.readInt(),
133 | };
134 | }
135 | readCompleteResponse(): CompleteResponse {
136 | return {
137 | id: this.readInt(),
138 | error: this.readString(),
139 | pos: this.readInt(),
140 | completions: this.readArray(() => this.readString()),
141 | };
142 | }
143 | readRunRequest(): RunRequest {
144 | return {
145 | cell: this.readInt(),
146 | cwd: this.readString(),
147 | argv: this.readArray(() => this.readString()),
148 | };
149 | }
150 | readKeyEvent(): KeyEvent {
151 | return {
152 | cell: this.readInt(),
153 | keys: this.readString(),
154 | };
155 | }
156 | readRowSpans(): RowSpans {
157 | return {
158 | row: this.readInt(),
159 | spans: this.readArray(() => this.readSpan()),
160 | };
161 | }
162 | readSpan(): Span {
163 | return {
164 | attr: this.readInt(),
165 | text: this.readString(),
166 | };
167 | }
168 | readCursor(): Cursor {
169 | return {
170 | row: this.readInt(),
171 | col: this.readInt(),
172 | hidden: this.readBoolean(),
173 | };
174 | }
175 | readTermUpdate(): TermUpdate {
176 | return {
177 | rows: this.readArray(() => this.readRowSpans()),
178 | cursor: this.readCursor(),
179 | rowCount: this.readInt(),
180 | };
181 | }
182 | readPair(): Pair {
183 | return {
184 | key: this.readString(),
185 | val: this.readString(),
186 | };
187 | }
188 | readHello(): Hello {
189 | return {
190 | alias: this.readArray(() => this.readPair()),
191 | env: this.readArray(() => this.readPair()),
192 | };
193 | }
194 | readCmdError(): CmdError {
195 | return {
196 | error: this.readString(),
197 | };
198 | }
199 | readExit(): Exit {
200 | return {
201 | exitCode: this.readInt(),
202 | };
203 | }
204 | readOutput(): Output {
205 | switch (this.readUint8()) {
206 | case 1:
207 | return { tag: 'CmdError', val: this.readCmdError() };
208 | case 2:
209 | return { tag: 'TermUpdate', val: this.readTermUpdate() };
210 | case 3:
211 | return { tag: 'Exit', val: this.readExit() };
212 | default:
213 | throw new Error('parse error');
214 | }
215 | }
216 | readCellOutput(): CellOutput {
217 | return {
218 | cell: this.readInt(),
219 | output: this.readOutput(),
220 | };
221 | }
222 | readServerMsg(): ServerMsg {
223 | switch (this.readUint8()) {
224 | case 1:
225 | return { tag: 'Hello', val: this.readHello() };
226 | case 2:
227 | return { tag: 'CompleteResponse', val: this.readCompleteResponse() };
228 | case 3:
229 | return { tag: 'CellOutput', val: this.readCellOutput() };
230 | default:
231 | throw new Error('parse error');
232 | }
233 | }
234 | }
235 | export class Writer {
236 | public ofs = 0;
237 | public buf = new Uint8Array();
238 | writeBoolean(val: boolean) {
239 | this.writeUint8(val ? 1 : 0);
240 | }
241 | writeUint8(val: number) {
242 | if (val > 0xff) throw new Error('overflow');
243 | this.buf[this.ofs++] = val;
244 | }
245 | writeInt(val: number) {
246 | if (val < 0) throw new Error('negative');
247 | for (;;) {
248 | const b = val & 0x7f;
249 | val = val >> 7;
250 | if (val === 0) {
251 | this.writeUint8(b);
252 | return;
253 | }
254 | this.writeUint8(b | 0x80);
255 | }
256 | }
257 | writeString(str: string) {
258 | this.writeInt(str.length);
259 | for (let i = 0; i < str.length; i++) {
260 | this.buf[this.ofs++] = str.charCodeAt(i);
261 | }
262 | }
263 | writeArray(arr: T[], f: (t: T) => void) {
264 | this.writeInt(arr.length);
265 | for (const elem of arr) {
266 | f(elem);
267 | }
268 | }
269 | writeClientMessage(msg: ClientMessage) {
270 | switch (msg.tag) {
271 | case 'CompleteRequest':
272 | this.writeUint8(1);
273 | this.writeCompleteRequest(msg.val);
274 | break;
275 | case 'RunRequest':
276 | this.writeUint8(2);
277 | this.writeRunRequest(msg.val);
278 | break;
279 | case 'KeyEvent':
280 | this.writeUint8(3);
281 | this.writeKeyEvent(msg.val);
282 | break;
283 | }
284 | }
285 | writeCompleteRequest(msg: CompleteRequest) {
286 | this.writeInt(msg.id);
287 | this.writeString(msg.cwd);
288 | this.writeString(msg.input);
289 | this.writeInt(msg.pos);
290 | }
291 | writeCompleteResponse(msg: CompleteResponse) {
292 | this.writeInt(msg.id);
293 | this.writeString(msg.error);
294 | this.writeInt(msg.pos);
295 | this.writeArray(msg.completions, (val) => {
296 | this.writeString(val);
297 | });
298 | }
299 | writeRunRequest(msg: RunRequest) {
300 | this.writeInt(msg.cell);
301 | this.writeString(msg.cwd);
302 | this.writeArray(msg.argv, (val) => {
303 | this.writeString(val);
304 | });
305 | }
306 | writeKeyEvent(msg: KeyEvent) {
307 | this.writeInt(msg.cell);
308 | this.writeString(msg.keys);
309 | }
310 | writeRowSpans(msg: RowSpans) {
311 | this.writeInt(msg.row);
312 | this.writeArray(msg.spans, (val) => {
313 | this.writeSpan(val);
314 | });
315 | }
316 | writeSpan(msg: Span) {
317 | this.writeInt(msg.attr);
318 | this.writeString(msg.text);
319 | }
320 | writeCursor(msg: Cursor) {
321 | this.writeInt(msg.row);
322 | this.writeInt(msg.col);
323 | this.writeBoolean(msg.hidden);
324 | }
325 | writeTermUpdate(msg: TermUpdate) {
326 | this.writeArray(msg.rows, (val) => {
327 | this.writeRowSpans(val);
328 | });
329 | this.writeCursor(msg.cursor);
330 | this.writeInt(msg.rowCount);
331 | }
332 | writePair(msg: Pair) {
333 | this.writeString(msg.key);
334 | this.writeString(msg.val);
335 | }
336 | writeHello(msg: Hello) {
337 | this.writeArray(msg.alias, (val) => {
338 | this.writePair(val);
339 | });
340 | this.writeArray(msg.env, (val) => {
341 | this.writePair(val);
342 | });
343 | }
344 | writeCmdError(msg: CmdError) {
345 | this.writeString(msg.error);
346 | }
347 | writeExit(msg: Exit) {
348 | this.writeInt(msg.exitCode);
349 | }
350 | writeOutput(msg: Output) {
351 | switch (msg.tag) {
352 | case 'CmdError':
353 | this.writeUint8(1);
354 | this.writeCmdError(msg.val);
355 | break;
356 | case 'TermUpdate':
357 | this.writeUint8(2);
358 | this.writeTermUpdate(msg.val);
359 | break;
360 | case 'Exit':
361 | this.writeUint8(3);
362 | this.writeExit(msg.val);
363 | break;
364 | }
365 | }
366 | writeCellOutput(msg: CellOutput) {
367 | this.writeInt(msg.cell);
368 | this.writeOutput(msg.output);
369 | }
370 | writeServerMsg(msg: ServerMsg) {
371 | switch (msg.tag) {
372 | case 'Hello':
373 | this.writeUint8(1);
374 | this.writeHello(msg.val);
375 | break;
376 | case 'CompleteResponse':
377 | this.writeUint8(2);
378 | this.writeCompleteResponse(msg.val);
379 | break;
380 | case 'CellOutput':
381 | this.writeUint8(3);
382 | this.writeCellOutput(msg.val);
383 | break;
384 | }
385 | }
386 | }
387 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2010
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/cli/cmd/smash/smash.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "fmt"
7 | "io"
8 | "log"
9 | "net"
10 | "net/http"
11 | "os"
12 | "os/exec"
13 | "path/filepath"
14 | "strings"
15 | "sync"
16 | "syscall"
17 | "time"
18 |
19 | "github.com/evmar/smash/bash"
20 | "github.com/evmar/smash/proto"
21 | "github.com/evmar/smash/vt100"
22 | "github.com/gorilla/websocket"
23 | "github.com/kr/pty"
24 | )
25 |
26 | var completer *bash.Bash
27 | var globalLastTermForCmd *vt100.Terminal
28 | var globalSockPathForEnv string
29 |
30 | var upgrader = websocket.Upgrader{
31 | ReadBufferSize: 1024,
32 | WriteBufferSize: 1024,
33 | EnableCompression: true,
34 | }
35 |
36 | // conn wraps a websocket.Conn with a lock.
37 | type conn struct {
38 | sync.Mutex
39 | ws *websocket.Conn
40 | }
41 |
42 | func (c *conn) writeMsg(msg proto.Msg) error {
43 | m := &proto.ServerMsg{msg}
44 | w := &bytes.Buffer{}
45 | if err := m.Write(w); err != nil {
46 | return err
47 | }
48 | c.Lock()
49 | defer c.Unlock()
50 | return c.ws.WriteMessage(websocket.BinaryMessage, w.Bytes())
51 | }
52 |
53 | // isPtyEOFError tests for a pty close error.
54 | // When a pty closes, you get an EIO error instead of an EOF.
55 | func isPtyEOFError(err error) bool {
56 | const EIO syscall.Errno = 5
57 | if perr, ok := err.(*os.PathError); ok {
58 | if errno, ok := perr.Err.(syscall.Errno); ok && errno == EIO {
59 | // read /dev/ptmx: input/output error
60 | return true
61 | }
62 | }
63 | return false
64 | }
65 |
66 | // command represents a subprocess running on behalf of the user.
67 | // req.Cell has the id of the command for use in protocol messages.
68 | type command struct {
69 | conn *conn
70 | // req is the initial request that caused the command to be spawned.
71 | req *proto.RunRequest
72 | cmd *exec.Cmd
73 |
74 | // stdin accepts input keys and forwards them to the subprocess.
75 | stdin chan []byte
76 | }
77 |
78 | func newCmd(conn *conn, req *proto.RunRequest) *command {
79 | cmd := &exec.Cmd{Path: req.Argv[0], Args: req.Argv}
80 | // TODO: accept environment from the client
81 | cmd.Env = os.Environ()
82 | cmd.Env = append(cmd.Env, "SMASH_SOCK="+globalSockPathForEnv)
83 | cmd.Dir = req.Cwd
84 | return &command{
85 | conn: conn,
86 | req: req,
87 | cmd: cmd,
88 | }
89 | }
90 |
91 | func (cmd *command) send(msg proto.Msg) error {
92 | return cmd.conn.writeMsg(&proto.CellOutput{
93 | Cell: cmd.req.Cell,
94 | Output: proto.Output{msg},
95 | })
96 | }
97 |
98 | func (cmd *command) sendError(msg string) error {
99 | return cmd.send(&proto.CmdError{msg})
100 | }
101 |
102 | func termLoop(tr *vt100.TermReader, r io.Reader) error {
103 | br := bufio.NewReader(r)
104 | for {
105 | if err := tr.Read(br); err != nil {
106 | if isPtyEOFError(err) {
107 | err = io.EOF
108 | }
109 | return err
110 | }
111 | }
112 | }
113 |
114 | // run synchronously runs the subprocess to completion, sending terminal
115 | // updates as it progresses. It may return errors if the subprocess failed
116 | // to run for whatever reason (e.g. no such path), and otherwise returns
117 | // the subprocess exit code.
118 | func (cmd *command) run() (int, error) {
119 | if cmd.cmd.Path == "cd" {
120 | if len(cmd.cmd.Args) != 2 {
121 | return 0, fmt.Errorf("bad arguments to cd")
122 | }
123 | dir := cmd.cmd.Args[1]
124 | st, err := os.Stat(dir)
125 | if err != nil {
126 | return 0, err
127 | }
128 | if !st.IsDir() {
129 | return 0, fmt.Errorf("%s: not a directory", dir)
130 | }
131 | return 0, nil
132 | }
133 |
134 | if filepath.Base(cmd.cmd.Path) == cmd.cmd.Path {
135 | // TODO: should use shell env $PATH.
136 | if p, err := exec.LookPath(cmd.cmd.Path); err != nil {
137 | return 0, err
138 | } else {
139 | cmd.cmd.Path = p
140 | }
141 | }
142 |
143 | size := pty.Winsize{
144 | Rows: 24,
145 | Cols: 80,
146 | }
147 | f, err := pty.StartWithSize(cmd.cmd, &size)
148 | if err != nil {
149 | return 0, err
150 | }
151 |
152 | cmd.stdin = make(chan []byte)
153 | go func() {
154 | for input := range cmd.stdin {
155 | f.Write(input)
156 | }
157 | }()
158 |
159 | var mu sync.Mutex // protects term, drawPending, and done
160 | wake := sync.NewCond(&mu)
161 | term := vt100.NewTerminal()
162 | drawPending := false
163 | var done error
164 |
165 | var tr *vt100.TermReader
166 | renderFromDirty := func() {
167 | // Called with mu held.
168 | allDirty := tr.Dirty.Lines[-1]
169 | update := &proto.TermUpdate{}
170 | if tr.Dirty.Cursor {
171 | update.Cursor = proto.Cursor{
172 | Row: term.Row,
173 | Col: term.Col,
174 | Hidden: term.HideCursor,
175 | }
176 | }
177 | for row, l := range term.Lines {
178 | // TODO: iterate tr.Dirty, not all lines.
179 | if !(allDirty || tr.Dirty.Lines[row]) {
180 | continue
181 | }
182 | rowSpans := proto.RowSpans{
183 | Row: row,
184 | }
185 | span := proto.Span{}
186 | var attr vt100.Attr
187 | for _, cell := range l {
188 | if cell.Attr != attr {
189 | attr = cell.Attr
190 | rowSpans.Spans = append(rowSpans.Spans, span)
191 | span = proto.Span{Attr: int(attr)}
192 | }
193 | // TODO: super inefficient.
194 | span.Text += fmt.Sprintf("%c", cell.Ch)
195 | }
196 | if len(span.Text) > 0 {
197 | rowSpans.Spans = append(rowSpans.Spans, span)
198 | }
199 | update.Rows = append(update.Rows, rowSpans)
200 | }
201 | update.RowCount = len(term.Lines)
202 |
203 | err := cmd.send(update)
204 | if err != nil {
205 | done = err
206 | }
207 | }
208 |
209 | tr = vt100.NewTermReader(func(f func(t *vt100.Terminal)) {
210 | // This is called from the 'go termLoop' goroutine,
211 | // when the vt100 impl wants to update the terminal.
212 | mu.Lock()
213 | f(term)
214 | if !drawPending {
215 | drawPending = true
216 | wake.Signal()
217 | }
218 | mu.Unlock()
219 | })
220 |
221 | go func() {
222 | err := termLoop(tr, f)
223 | mu.Lock()
224 | done = err
225 | wake.Signal()
226 | mu.Unlock()
227 | }()
228 |
229 | for {
230 | mu.Lock()
231 | for !drawPending && done == nil {
232 | wake.Wait()
233 | }
234 |
235 | if done == nil {
236 | mu.Unlock()
237 | // Allow more pending paints to enqueue.
238 | time.Sleep(10 * time.Millisecond)
239 | mu.Lock()
240 | }
241 |
242 | if drawPending { // There can be no draw pending if done != nil.
243 | renderFromDirty()
244 | tr.Dirty.Reset()
245 | drawPending = false
246 | }
247 |
248 | mu.Unlock()
249 |
250 | if done != nil {
251 | break
252 | }
253 | }
254 |
255 | mu.Lock()
256 | globalLastTermForCmd = term
257 |
258 | // done is the error reported by the terminal.
259 | // We expect EOF in normal execution.
260 | if done != io.EOF {
261 | return 0, err
262 | }
263 |
264 | // Reap the subprocess and report the exit code.
265 | if err := cmd.cmd.Wait(); err != nil {
266 | if eerr, ok := err.(*exec.ExitError); ok {
267 | serr := eerr.Sys().(syscall.WaitStatus)
268 | return serr.ExitStatus(), nil
269 | } else {
270 | return 0, err
271 | }
272 | }
273 | return 0, nil
274 | }
275 |
276 | // runHandlingErrors calls run() and forwards any subprocess errors
277 | // on to the client.
278 | func (cmd *command) runHandlingErrors() {
279 | exitCode, err := cmd.run()
280 | if err != nil {
281 | cmd.sendError(err.Error())
282 | exitCode = 1
283 | }
284 | if exitCode < 0 {
285 | exitCode = 1 // TODO: negative exit codes from signals
286 | }
287 | cmd.send(&proto.Exit{exitCode})
288 | }
289 |
290 | var localCommands = map[string]func(w io.Writer) error{
291 | "that": func(w io.Writer) error {
292 | if globalLastTermForCmd == nil {
293 | return nil
294 | }
295 | _, err := io.WriteString(w, globalLastTermForCmd.ToString())
296 | return err
297 | },
298 | }
299 |
300 | func getEnv() map[string]string {
301 | env := map[string]string{}
302 | for _, keyval := range os.Environ() {
303 | eq := strings.Index(keyval, "=")
304 | if eq < 0 {
305 | panic("bad env?")
306 | }
307 | env[keyval[:eq]] = keyval[eq+1:]
308 | }
309 | return env
310 | }
311 |
312 | func mapPairs(m map[string]string) []proto.Pair {
313 | pairs := []proto.Pair{}
314 | for k, v := range m {
315 | pairs = append(pairs, proto.Pair{k, v})
316 | }
317 | return pairs
318 | }
319 |
320 | func serveWS(w http.ResponseWriter, r *http.Request) error {
321 | wsConn, err := upgrader.Upgrade(w, r, nil)
322 | if err != nil {
323 | return err
324 | }
325 | conn := &conn{
326 | ws: wsConn,
327 | }
328 |
329 | smashPath, err := os.Readlink("/proc/self/exe")
330 | if err != nil {
331 | return err
332 | }
333 |
334 | aliases, err := bash.GetAliases()
335 | if err != nil {
336 | return err
337 | }
338 | env := getEnv()
339 | env["SMASH"] = smashPath
340 | env["SMASH_SOCK"] = globalSockPathForEnv
341 | hello := &proto.Hello{
342 | Alias: mapPairs(aliases),
343 | Env: mapPairs(env),
344 | }
345 | if err = conn.writeMsg(hello); err != nil {
346 | return err
347 | }
348 |
349 | commands := map[int]*command{}
350 | for {
351 | _, buf, err := conn.ws.ReadMessage()
352 | if err != nil {
353 | return fmt.Errorf("reading client message: %s", err)
354 | }
355 | var msg proto.ClientMessage
356 | if err := msg.Read(bufio.NewReader(bytes.NewBuffer(buf))); err != nil {
357 | return fmt.Errorf("parsing client message: %s", err)
358 | }
359 |
360 | switch msg := msg.Alt.(type) {
361 | case *proto.RunRequest:
362 | cmd := newCmd(conn, msg)
363 | commands[int(msg.Cell)] = cmd
364 | go cmd.runHandlingErrors()
365 | case *proto.KeyEvent:
366 | cmd := commands[int(msg.Cell)]
367 | if cmd == nil {
368 | log.Println("got key msg for unknown command", msg.Cell)
369 | continue
370 | }
371 | // TODO: what if cmd failed?
372 | // TODO: what if pipe is blocked?
373 | cmd.stdin <- []byte(msg.Keys)
374 | case *proto.CompleteRequest:
375 | if msg.Cwd == "" {
376 | panic("incomplete complete request")
377 | }
378 | go func() {
379 | if err := completer.Chdir(msg.Cwd); err != nil {
380 | log.Println(err) // TODO
381 | }
382 | pos, completions, err := completer.Complete(msg.Input[0:msg.Pos])
383 | if err != nil {
384 | log.Println(err) // TODO
385 | }
386 | err = conn.writeMsg(&proto.CompleteResponse{
387 | Id: msg.Id,
388 | Pos: pos,
389 | Completions: completions,
390 | })
391 | if err != nil {
392 | log.Println(err) // TODO
393 | }
394 | }()
395 | default:
396 | log.Println("unhandled msg", msg)
397 | }
398 | }
399 | }
400 |
401 | func serve() error {
402 | sockPath, localSock, err := setupLocalCommandSock()
403 | if err != nil {
404 | return err
405 | }
406 | go func() {
407 | if err := readLocalCommands(localSock); err != nil {
408 | fmt.Fprintf(os.Stderr, "local sock: %s\n", err)
409 | }
410 | }()
411 | globalSockPathForEnv = sockPath
412 |
413 | b, err := bash.StartBash()
414 | if err != nil {
415 | return err
416 | }
417 | completer = b
418 |
419 | http.Handle("/", http.FileServer(http.Dir("../web/dist")))
420 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
421 | if err := serveWS(w, r); err != nil {
422 | log.Printf("error: %s", err)
423 | }
424 | })
425 | addr := "localhost:8080"
426 | fmt.Printf("listening on %q\n", addr)
427 | return http.ListenAndServe(addr, nil)
428 | }
429 |
430 | func localCommand(cmd string) error {
431 | sockPath := os.Getenv("SMASH_SOCK")
432 | if sockPath == "" {
433 | return fmt.Errorf("no $SMASH_SOCK; are you running under smash?")
434 | }
435 |
436 | conn, err := net.Dial("unix", sockPath)
437 | if err != nil {
438 | return err
439 | }
440 | defer conn.Close()
441 | if _, err = fmt.Fprintf(conn, "%s\n", cmd); err != nil {
442 | return err
443 | }
444 | if _, err = io.Copy(os.Stdout, conn); err != nil {
445 | return err
446 | }
447 | return nil
448 | }
449 |
450 | func main() {
451 | var cmd = "serve"
452 | if len(os.Args) > 1 {
453 | cmd = os.Args[1]
454 | }
455 |
456 | var err error
457 | if _, isLocal := localCommands[cmd]; isLocal {
458 | err = localCommand(cmd)
459 | } else {
460 | switch cmd {
461 | case "serve":
462 | err = serve()
463 | case "help":
464 | default:
465 | fmt.Println("TODO: usage")
466 | }
467 | }
468 |
469 | if err != nil {
470 | fmt.Fprintf(os.Stderr, "ERROR: %s\n", err)
471 | os.Exit(1)
472 | }
473 | }
474 |
--------------------------------------------------------------------------------
/web/src/readline.ts:
--------------------------------------------------------------------------------
1 | import { html, htext } from './html';
2 |
3 | export function translateKey(ev: KeyboardEvent): string {
4 | switch (ev.key) {
5 | case 'Alt':
6 | case 'Control':
7 | case 'Shift':
8 | case 'Unidentified':
9 | return '';
10 | }
11 | // Avoid browser tab switch keys:
12 | if (ev.key >= '0' && ev.key <= '9') return '';
13 |
14 | let name = '';
15 | if (ev.altKey) name += 'M-';
16 | if (ev.ctrlKey) name += 'C-';
17 | if (ev.shiftKey && ev.key.length > 1) name += 'S-';
18 | name += ev.key;
19 | return name;
20 | }
21 |
22 | export interface CompleteRequest {
23 | input: string;
24 | pos: number;
25 | }
26 |
27 | export interface CompleteResponse {
28 | completions: string[];
29 | pos: number;
30 | }
31 |
32 | class CompletePopup {
33 | dom = html('div', { className: 'popup', style: { overflow: 'hidden' } });
34 | textSize!: { width: number; height: number };
35 | selection = -1;
36 |
37 | delegates = {
38 | oncommit: (text: string, pos: number): void => {},
39 | };
40 |
41 | constructor(readonly req: CompleteRequest, readonly resp: CompleteResponse) {}
42 |
43 | show(parent: HTMLElement) {
44 | this.textSize = this.measure(
45 | parent,
46 | this.req.input.substring(0, this.resp.pos) + '\u200b'
47 | );
48 |
49 | for (const comp of this.resp.completions) {
50 | const dom = html('div', { className: 'completion' }, htext(comp));
51 | // Listen to mousedown because if we listen to click, the click causes
52 | // the input field to lose focus.
53 | dom.addEventListener('mousedown', (event) => {
54 | this.delegates.oncommit(comp, this.resp.pos);
55 | event.preventDefault();
56 | });
57 | this.dom.appendChild(dom);
58 | }
59 | parent.appendChild(this.dom);
60 | this.position();
61 | this.selectCompletion(0);
62 | this.dom.focus();
63 | }
64 |
65 | /** Measures the size of the given text as if it were contained in the parent. */
66 | private measure(
67 | parent: HTMLElement,
68 | text: string
69 | ): { width: number; height: number } {
70 | const measure = html(
71 | 'div',
72 | {
73 | style: {
74 | position: 'absolute',
75 | visibility: 'hidden',
76 | whiteSpace: 'pre',
77 | },
78 | },
79 | htext(text)
80 | );
81 | parent.appendChild(measure);
82 | const { width, height } = getComputedStyle(measure);
83 | parent.removeChild(measure);
84 | return { width: parseFloat(width), height: parseFloat(height) };
85 | }
86 |
87 | /** Positions this.dom. */
88 | private position() {
89 | // Careful about units here. The element is positioned relative to the input
90 | // box, but we want to measure things in terms of whether they fit in the current
91 | // viewport.
92 | //
93 | // Also, the popup may not fit. Options in order of preference:
94 | // 1. Pop up below, if it fits.
95 | // 2. Pop up above, if it fits.
96 | // 3. Pop up in whichever side has more space, but truncated.
97 |
98 | // promptX/promptY are in viewport coordinates.
99 | const promptY = (this.dom.parentNode as HTMLElement).getClientRects()[0].y;
100 | const popupHeight = this.dom.offsetHeight;
101 |
102 | const spaceAbove = promptY;
103 | const spaceBelow = window.innerHeight - (promptY + this.textSize.height);
104 |
105 | let placeBelow: boolean;
106 | if (spaceBelow >= popupHeight) {
107 | placeBelow = true;
108 | } else if (spaceAbove >= popupHeight) {
109 | placeBelow = false;
110 | } else {
111 | placeBelow = spaceBelow >= spaceAbove;
112 | }
113 |
114 | const popupPaddingY = 2 + 2; // 2 above, 2 below
115 | const popupShadowY = 4; // arbitrary fudge factor
116 | const popupSizeMargin = popupPaddingY + popupShadowY;
117 |
118 | if (placeBelow) {
119 | this.dom.style.top = `${this.textSize.height}px`;
120 | this.dom.style.bottom = '';
121 | this.dom.style.height =
122 | spaceBelow >= popupHeight ? '' : `${spaceBelow - popupSizeMargin}px`;
123 | } else {
124 | this.dom.style.top = '';
125 | this.dom.style.bottom = `${this.textSize.height}px`;
126 | this.dom.style.height =
127 | spaceAbove >= popupHeight ? '' : `${spaceAbove - popupSizeMargin}px`;
128 | }
129 |
130 | const popupPaddingLeft = 4;
131 | this.dom.style.left = `${this.textSize.width - popupPaddingLeft}px`;
132 | }
133 |
134 | hide() {
135 | this.dom.parentNode!.removeChild(this.dom);
136 | }
137 |
138 | private selectCompletion(index: number) {
139 | if (this.selection !== -1) {
140 | this.dom.children[this.selection].classList.remove('selected');
141 | }
142 | this.selection =
143 | (index + this.resp.completions.length) % this.resp.completions.length;
144 | this.dom.children[this.selection].classList.add('selected');
145 | }
146 |
147 | /** @param key The key name as produced by translateKey(). */
148 | handleKey(key: string): boolean {
149 | switch (key) {
150 | case 'ArrowDown':
151 | case 'Tab':
152 | case 'C-n':
153 | this.selectCompletion(this.selection + 1);
154 | return true;
155 | case 'ArrowUp':
156 | case 'S-Tab':
157 | case 'C-p':
158 | this.selectCompletion(this.selection - 1);
159 | return true;
160 | case 'Enter':
161 | this.delegates.oncommit(
162 | this.resp.completions[this.selection],
163 | this.resp.pos
164 | );
165 | return true;
166 | case 'Escape':
167 | this.delegates.oncommit('', this.resp.pos);
168 | return true;
169 | }
170 | return false; // Pop down on any other key.
171 | }
172 | }
173 |
174 | /** Returns the length of the longest prefix shared by all input strings. */
175 | function longestSharedPrefixLength(strs: string[]): number {
176 | for (let len = 0; ; len++) {
177 | let c = -1;
178 | for (const str of strs) {
179 | if (len === str.length) return len;
180 | if (c === -1) c = str.charCodeAt(len);
181 | else if (str.charCodeAt(len) !== c) return len;
182 | }
183 | }
184 | }
185 |
186 | export function backwardWordBoundary(text: string, pos: number): number {
187 | // If at a word start already, skip preceding whitespace.
188 | for (; pos > 0; pos--) {
189 | if (text.charAt(pos - 1) !== ' ') break;
190 | }
191 | // Skip to the beginning of the current word.
192 | for (; pos > 0; pos--) {
193 | if (text.charAt(pos - 1) === ' ') break;
194 | }
195 | return pos;
196 | }
197 |
198 | function forwardWordBoundary(text: string, pos: number): number {
199 | for (; pos < text.length; pos++) {
200 | if (text.charAt(pos) === ' ') break;
201 | }
202 | for (; pos < text.length; pos++) {
203 | if (text.charAt(pos) !== ' ') break;
204 | }
205 | return pos;
206 | }
207 |
208 | export interface InputState {
209 | text: string;
210 | start: number;
211 | end: number;
212 | }
213 |
214 | export interface InputHandler {
215 | onEnter(state: InputState): void;
216 | tabComplete(state: InputState): void;
217 | setText(text: string): void;
218 | setPos(pos: number): void;
219 | showHistory(delta: -1 | 0 | 1): void;
220 | }
221 |
222 | export function interpretKey(
223 | state: InputState,
224 | key: string,
225 | handler: InputHandler
226 | ): boolean {
227 | const { text, start } = state;
228 | switch (key) {
229 | case 'Enter':
230 | handler.onEnter(state);
231 | return true;
232 | case 'Tab':
233 | handler.tabComplete(state);
234 | return true;
235 | case 'Delete': // At least on ChromeOS, this is M-Backspace.
236 | case 'M-Backspace': {
237 | // backward-kill-word
238 | const wordStart = backwardWordBoundary(text, start);
239 | handler.setPos(wordStart);
240 | handler.setText(text.substring(0, wordStart) + text.substring(start));
241 | return true;
242 | }
243 | case 'C-a':
244 | case 'Home':
245 | handler.setPos(0);
246 | return true;
247 | case 'C-b':
248 | handler.setPos(start - 1);
249 | return true;
250 | case 'M-b':
251 | handler.setPos(backwardWordBoundary(text, start));
252 | return true;
253 | case 'M-d': {
254 | const delEnd = forwardWordBoundary(text, start);
255 | handler.setText(text.substring(0, start) + text.substring(delEnd));
256 | return true;
257 | }
258 | case 'C-e':
259 | case 'End':
260 | handler.setPos(text.length);
261 | return true;
262 | case 'C-f':
263 | handler.setPos(start + 1);
264 | return true;
265 | case 'M-f':
266 | handler.setPos(forwardWordBoundary(text, start));
267 | return true;
268 | case 'C-k':
269 | handler.setText(text.substr(0, start));
270 | return true;
271 | case 'C-n':
272 | case 'ArrowDown':
273 | handler.showHistory(-1);
274 | return true;
275 | case 'C-p':
276 | case 'ArrowUp':
277 | handler.showHistory(1);
278 | return true;
279 | case 'C-u':
280 | handler.setText(text.substr(start));
281 | return true;
282 |
283 | case 'C-x': // browser: cut
284 | case 'C-c': // browser: copy
285 | case 'C-v': // browser: paste
286 | case 'C-J': // browser: inspector
287 | case 'C-l': // browser: location
288 | case 'C-R': // browser: reload
289 | // Allow default handling.
290 | return false;
291 | default:
292 | handler.showHistory(0);
293 | return false;
294 | }
295 | }
296 |
297 | export interface History {
298 | add(cmd: string): void;
299 | get(ofs: number): string | undefined;
300 | }
301 |
302 | export class ReadLine {
303 | dom = html('div', { className: 'readline' });
304 | prompt = html('div', { className: 'prompt' });
305 | inputBox = html('div', { className: 'input-box' });
306 | input = html('input', {
307 | spellcheck: false,
308 | }) as HTMLInputElement;
309 |
310 | delegates = {
311 | oncommit: (text: string): void => {},
312 | oncomplete: async (req: CompleteRequest): Promise => {
313 | throw 'notimpl';
314 | },
315 | };
316 |
317 | pendingComplete: Promise | undefined;
318 | popup: CompletePopup | undefined;
319 |
320 | /** Offset into the history: "we have gone N commands back". */
321 | historyPosition = 0;
322 |
323 | /**
324 | * The selection span at time of last blur.
325 | * This is restored on focus, to defeat the browser behavior of
326 | * select all on focus.
327 | */
328 | selection: [number, number] = [0, 0];
329 |
330 | constructor(private history: History) {
331 | this.dom.appendChild(this.prompt);
332 |
333 | this.inputBox.appendChild(this.input);
334 | this.dom.appendChild(this.inputBox);
335 |
336 | this.input.onkeydown = (ev) => {
337 | const key = translateKey(ev);
338 | if (!key) return;
339 | if (this.handleKey(key)) ev.preventDefault();
340 | };
341 | this.input.onkeypress = (ev) => {
342 | const key = ev.key;
343 | if (!key) return;
344 | if (this.handleKey(key)) ev.preventDefault();
345 | };
346 |
347 | // Catch focus/blur events, per docs on this.selection.
348 | this.input.addEventListener('blur', () => {
349 | this.selection = [this.input.selectionStart!, this.input.selectionEnd!];
350 | this.pendingComplete = undefined;
351 | this.hidePopup();
352 | });
353 | this.input.addEventListener('focus', () => {
354 | [this.input.selectionStart, this.input.selectionEnd] = this.selection;
355 | });
356 | }
357 |
358 | setPrompt(text: string) {
359 | this.prompt.innerText = `${text}$ `;
360 | }
361 |
362 | setText(text: string) {
363 | this.input.value = text;
364 | this.historyPosition = 0;
365 | }
366 |
367 | setPos(pos: number) {
368 | pos = Math.max(0, Math.min(this.input.value.length, pos));
369 | this.input.selectionStart = this.input.selectionEnd = pos;
370 | }
371 |
372 | showHistory(delta: -1 | 0 | 1) {
373 | switch (delta) {
374 | case -1: {
375 | if (this.historyPosition === 0) return;
376 | this.historyPosition--;
377 | const cmd = this.history.get(this.historyPosition) || '';
378 | this.input.value = cmd;
379 | return;
380 | }
381 | case 1: {
382 | const cmd = this.history.get(this.historyPosition + 1);
383 | if (!cmd) return;
384 | this.historyPosition++;
385 | this.input.value = cmd;
386 | return;
387 | }
388 | case 0:
389 | this.historyPosition = 0;
390 | return;
391 | }
392 | }
393 |
394 | focus() {
395 | this.input.focus();
396 | }
397 |
398 | hidePopup() {
399 | if (!this.popup) return;
400 | this.popup.hide();
401 | this.popup = undefined;
402 | }
403 |
404 | /** @param key The key name as produced by translateKey(). */
405 | handleKey(key: string): boolean {
406 | if (this.popup && this.popup.handleKey(key)) return true;
407 | if (this.pendingComplete) this.pendingComplete = undefined;
408 | this.hidePopup();
409 |
410 | const state: InputState = {
411 | text: this.input.value,
412 | start: this.input.selectionStart ?? 0,
413 | end: this.input.selectionEnd ?? 0,
414 | };
415 | return interpretKey(state, key, this);
416 | }
417 |
418 | tabComplete(state: InputState) {
419 | const pos = state.start;
420 | const req: CompleteRequest = { input: state.text, pos };
421 | const pending = (this.pendingComplete = this.delegates.oncomplete(req));
422 | pending.then((resp) => {
423 | if (pending !== this.pendingComplete) return;
424 | this.pendingComplete = undefined;
425 | if (resp.completions.length === 0) return;
426 | const len = longestSharedPrefixLength(resp.completions);
427 | if (len > 0) {
428 | this.applyCompletion(resp.completions[0].substring(0, len), resp.pos);
429 | }
430 | // If there was only one completion, it's already been applied, so
431 | // there is nothing else to do.
432 | if (resp.completions.length > 1) {
433 | // Show a popup for the completions.
434 | this.popup = new CompletePopup(req, resp);
435 | this.popup.show(this.inputBox);
436 | this.popup.delegates = {
437 | oncommit: (text: string, pos: number) => {
438 | this.applyCompletion(text, pos);
439 | this.hidePopup();
440 | },
441 | };
442 | }
443 | });
444 | }
445 |
446 | applyCompletion(text: string, pos: number) {
447 | // The completion for a partial input may include some of that
448 | // partial input. Elide any text from the completion that already
449 | // exists in the input at that same position.
450 | let overlap = 0;
451 | while (
452 | pos + overlap < this.input.value.length &&
453 | this.input.value[pos + overlap] === text[overlap]
454 | ) {
455 | overlap++;
456 | }
457 | this.setText(
458 | this.input.value.substring(0, pos) +
459 | text +
460 | this.input.value.substring(pos + overlap)
461 | );
462 | }
463 |
464 | onEnter() {
465 | const text = this.input.value;
466 | this.history.add(text);
467 | this.delegates.oncommit(text);
468 | }
469 | }
470 |
--------------------------------------------------------------------------------
/cli/proto/smash.go:
--------------------------------------------------------------------------------
1 | package proto
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "io"
7 | )
8 |
9 | type Msg interface {
10 | Write(w io.Writer) error
11 | Read(r *bufio.Reader) error
12 | }
13 |
14 | func ReadBoolean(r *bufio.Reader) (bool, error) {
15 | val, err := ReadUint8(r)
16 | if err != nil {
17 | return false, err
18 | }
19 | return val == 1, nil
20 | }
21 |
22 | func ReadUint8(r *bufio.Reader) (byte, error) {
23 | return r.ReadByte()
24 | }
25 |
26 | func ReadInt(r *bufio.Reader) (int, error) {
27 | shift := 0
28 | var val int
29 | for {
30 | b, err := r.ReadByte()
31 | if err != nil {
32 | return 0, err
33 | }
34 | val |= int(b&0b0111_1111) << shift
35 | if (b & 0b1000_0000) == 0 {
36 | return val, nil
37 | }
38 | shift = shift + 7
39 | }
40 | }
41 |
42 | func ReadString(r *bufio.Reader) (string, error) {
43 | n, err := ReadInt(r)
44 | if err != nil {
45 | return "", err
46 | }
47 | buf := make([]byte, n)
48 | _, err = io.ReadFull(r, buf)
49 | if err != nil {
50 | return "", err
51 | }
52 | return string(buf), nil
53 | }
54 |
55 | func WriteBoolean(w io.Writer, val bool) error {
56 | if val {
57 | return WriteUint8(w, 1)
58 | } else {
59 | return WriteUint8(w, 0)
60 | }
61 | }
62 | func WriteUint8(w io.Writer, val byte) error {
63 | buf := [1]byte{val}
64 | _, err := w.Write(buf[:])
65 | return err
66 | }
67 | func WriteInt(w io.Writer, val int) error {
68 | if val < 0 {
69 | panic("negative")
70 | }
71 | for {
72 | b := byte(val & 0b0111_1111)
73 | val = val >> 7
74 | if val == 0 {
75 | return WriteUint8(w, b)
76 | } else {
77 | if err := WriteUint8(w, b|0b1000_0000); err != nil {
78 | return err
79 | }
80 | }
81 | }
82 | }
83 | func WriteString(w io.Writer, str string) error {
84 | if len(str) >= 1<<16 {
85 | panic("overlong")
86 | }
87 | if err := WriteInt(w, len(str)); err != nil {
88 | return err
89 | }
90 | _, err := w.Write([]byte(str))
91 | return err
92 | }
93 |
94 | type ClientMessage struct {
95 | // CompleteRequest, RunRequest, KeyEvent
96 | Alt Msg
97 | }
98 | type CompleteRequest struct {
99 | Id int
100 | Cwd string
101 | Input string
102 | Pos int
103 | }
104 | type CompleteResponse struct {
105 | Id int
106 | Error string
107 | Pos int
108 | Completions []string
109 | }
110 | type RunRequest struct {
111 | Cell int
112 | Cwd string
113 | Argv []string
114 | }
115 | type KeyEvent struct {
116 | Cell int
117 | Keys string
118 | }
119 | type RowSpans struct {
120 | Row int
121 | Spans []Span
122 | }
123 | type Span struct {
124 | Attr int
125 | Text string
126 | }
127 | type Cursor struct {
128 | Row int
129 | Col int
130 | Hidden bool
131 | }
132 | type TermUpdate struct {
133 | Rows []RowSpans
134 | Cursor Cursor
135 | RowCount int
136 | }
137 | type Pair struct {
138 | Key string
139 | Val string
140 | }
141 | type Hello struct {
142 | Alias []Pair
143 | Env []Pair
144 | }
145 | type CmdError struct {
146 | Error string
147 | }
148 | type Exit struct {
149 | ExitCode int
150 | }
151 | type Output struct {
152 | // CmdError, TermUpdate, Exit
153 | Alt Msg
154 | }
155 | type CellOutput struct {
156 | Cell int
157 | Output Output
158 | }
159 | type ServerMsg struct {
160 | // Hello, CompleteResponse, CellOutput
161 | Alt Msg
162 | }
163 |
164 | func (msg *ClientMessage) Write(w io.Writer) error {
165 | switch alt := msg.Alt.(type) {
166 | case *CompleteRequest:
167 | if err := WriteUint8(w, 1); err != nil {
168 | return err
169 | }
170 | return alt.Write(w)
171 | case *RunRequest:
172 | if err := WriteUint8(w, 2); err != nil {
173 | return err
174 | }
175 | return alt.Write(w)
176 | case *KeyEvent:
177 | if err := WriteUint8(w, 3); err != nil {
178 | return err
179 | }
180 | return alt.Write(w)
181 | }
182 | panic("notimpl")
183 | }
184 | func (msg *CompleteRequest) Write(w io.Writer) error {
185 | if err := WriteInt(w, msg.Id); err != nil {
186 | return err
187 | }
188 | if err := WriteString(w, msg.Cwd); err != nil {
189 | return err
190 | }
191 | if err := WriteString(w, msg.Input); err != nil {
192 | return err
193 | }
194 | if err := WriteInt(w, msg.Pos); err != nil {
195 | return err
196 | }
197 | return nil
198 | }
199 | func (msg *CompleteResponse) Write(w io.Writer) error {
200 | if err := WriteInt(w, msg.Id); err != nil {
201 | return err
202 | }
203 | if err := WriteString(w, msg.Error); err != nil {
204 | return err
205 | }
206 | if err := WriteInt(w, msg.Pos); err != nil {
207 | return err
208 | }
209 | if err := WriteInt(w, len(msg.Completions)); err != nil {
210 | return err
211 | }
212 | for _, val := range msg.Completions {
213 | if err := WriteString(w, val); err != nil {
214 | return err
215 | }
216 | }
217 | return nil
218 | }
219 | func (msg *RunRequest) Write(w io.Writer) error {
220 | if err := WriteInt(w, msg.Cell); err != nil {
221 | return err
222 | }
223 | if err := WriteString(w, msg.Cwd); err != nil {
224 | return err
225 | }
226 | if err := WriteInt(w, len(msg.Argv)); err != nil {
227 | return err
228 | }
229 | for _, val := range msg.Argv {
230 | if err := WriteString(w, val); err != nil {
231 | return err
232 | }
233 | }
234 | return nil
235 | }
236 | func (msg *KeyEvent) Write(w io.Writer) error {
237 | if err := WriteInt(w, msg.Cell); err != nil {
238 | return err
239 | }
240 | if err := WriteString(w, msg.Keys); err != nil {
241 | return err
242 | }
243 | return nil
244 | }
245 | func (msg *RowSpans) Write(w io.Writer) error {
246 | if err := WriteInt(w, msg.Row); err != nil {
247 | return err
248 | }
249 | if err := WriteInt(w, len(msg.Spans)); err != nil {
250 | return err
251 | }
252 | for _, val := range msg.Spans {
253 | if err := val.Write(w); err != nil {
254 | return err
255 | }
256 | }
257 | return nil
258 | }
259 | func (msg *Span) Write(w io.Writer) error {
260 | if err := WriteInt(w, msg.Attr); err != nil {
261 | return err
262 | }
263 | if err := WriteString(w, msg.Text); err != nil {
264 | return err
265 | }
266 | return nil
267 | }
268 | func (msg *Cursor) Write(w io.Writer) error {
269 | if err := WriteInt(w, msg.Row); err != nil {
270 | return err
271 | }
272 | if err := WriteInt(w, msg.Col); err != nil {
273 | return err
274 | }
275 | if err := WriteBoolean(w, msg.Hidden); err != nil {
276 | return err
277 | }
278 | return nil
279 | }
280 | func (msg *TermUpdate) Write(w io.Writer) error {
281 | if err := WriteInt(w, len(msg.Rows)); err != nil {
282 | return err
283 | }
284 | for _, val := range msg.Rows {
285 | if err := val.Write(w); err != nil {
286 | return err
287 | }
288 | }
289 | if err := msg.Cursor.Write(w); err != nil {
290 | return err
291 | }
292 | if err := WriteInt(w, msg.RowCount); err != nil {
293 | return err
294 | }
295 | return nil
296 | }
297 | func (msg *Pair) Write(w io.Writer) error {
298 | if err := WriteString(w, msg.Key); err != nil {
299 | return err
300 | }
301 | if err := WriteString(w, msg.Val); err != nil {
302 | return err
303 | }
304 | return nil
305 | }
306 | func (msg *Hello) Write(w io.Writer) error {
307 | if err := WriteInt(w, len(msg.Alias)); err != nil {
308 | return err
309 | }
310 | for _, val := range msg.Alias {
311 | if err := val.Write(w); err != nil {
312 | return err
313 | }
314 | }
315 | if err := WriteInt(w, len(msg.Env)); err != nil {
316 | return err
317 | }
318 | for _, val := range msg.Env {
319 | if err := val.Write(w); err != nil {
320 | return err
321 | }
322 | }
323 | return nil
324 | }
325 | func (msg *CmdError) Write(w io.Writer) error {
326 | if err := WriteString(w, msg.Error); err != nil {
327 | return err
328 | }
329 | return nil
330 | }
331 | func (msg *Exit) Write(w io.Writer) error {
332 | if err := WriteInt(w, msg.ExitCode); err != nil {
333 | return err
334 | }
335 | return nil
336 | }
337 | func (msg *Output) Write(w io.Writer) error {
338 | switch alt := msg.Alt.(type) {
339 | case *CmdError:
340 | if err := WriteUint8(w, 1); err != nil {
341 | return err
342 | }
343 | return alt.Write(w)
344 | case *TermUpdate:
345 | if err := WriteUint8(w, 2); err != nil {
346 | return err
347 | }
348 | return alt.Write(w)
349 | case *Exit:
350 | if err := WriteUint8(w, 3); err != nil {
351 | return err
352 | }
353 | return alt.Write(w)
354 | }
355 | panic("notimpl")
356 | }
357 | func (msg *CellOutput) Write(w io.Writer) error {
358 | if err := WriteInt(w, msg.Cell); err != nil {
359 | return err
360 | }
361 | if err := msg.Output.Write(w); err != nil {
362 | return err
363 | }
364 | return nil
365 | }
366 | func (msg *ServerMsg) Write(w io.Writer) error {
367 | switch alt := msg.Alt.(type) {
368 | case *Hello:
369 | if err := WriteUint8(w, 1); err != nil {
370 | return err
371 | }
372 | return alt.Write(w)
373 | case *CompleteResponse:
374 | if err := WriteUint8(w, 2); err != nil {
375 | return err
376 | }
377 | return alt.Write(w)
378 | case *CellOutput:
379 | if err := WriteUint8(w, 3); err != nil {
380 | return err
381 | }
382 | return alt.Write(w)
383 | }
384 | panic("notimpl")
385 | }
386 | func (msg *ClientMessage) Read(r *bufio.Reader) error {
387 | alt, err := r.ReadByte()
388 | if err != nil {
389 | return err
390 | }
391 | switch alt {
392 | case 1:
393 | var val CompleteRequest
394 | if err := val.Read(r); err != nil {
395 | return err
396 | }
397 | msg.Alt = &val
398 | return nil
399 | case 2:
400 | var val RunRequest
401 | if err := val.Read(r); err != nil {
402 | return err
403 | }
404 | msg.Alt = &val
405 | return nil
406 | case 3:
407 | var val KeyEvent
408 | if err := val.Read(r); err != nil {
409 | return err
410 | }
411 | msg.Alt = &val
412 | return nil
413 | default:
414 | return fmt.Errorf("bad tag %d when reading ClientMessage", alt)
415 | }
416 | }
417 | func (msg *CompleteRequest) Read(r *bufio.Reader) error {
418 | var err error
419 | err = err
420 | msg.Id, err = ReadInt(r)
421 | if err != nil {
422 | return err
423 | }
424 | msg.Cwd, err = ReadString(r)
425 | if err != nil {
426 | return err
427 | }
428 | msg.Input, err = ReadString(r)
429 | if err != nil {
430 | return err
431 | }
432 | msg.Pos, err = ReadInt(r)
433 | if err != nil {
434 | return err
435 | }
436 | return nil
437 | }
438 | func (msg *CompleteResponse) Read(r *bufio.Reader) error {
439 | var err error
440 | err = err
441 | msg.Id, err = ReadInt(r)
442 | if err != nil {
443 | return err
444 | }
445 | msg.Error, err = ReadString(r)
446 | if err != nil {
447 | return err
448 | }
449 | msg.Pos, err = ReadInt(r)
450 | if err != nil {
451 | return err
452 | }
453 | {
454 | n, err := ReadInt(r)
455 | if err != nil {
456 | return err
457 | }
458 | var val string
459 | for i := 0; i < n; i++ {
460 | val, err = ReadString(r)
461 | if err != nil {
462 | return err
463 | }
464 | msg.Completions = append(msg.Completions, val)
465 | }
466 | }
467 | return nil
468 | }
469 | func (msg *RunRequest) Read(r *bufio.Reader) error {
470 | var err error
471 | err = err
472 | msg.Cell, err = ReadInt(r)
473 | if err != nil {
474 | return err
475 | }
476 | msg.Cwd, err = ReadString(r)
477 | if err != nil {
478 | return err
479 | }
480 | {
481 | n, err := ReadInt(r)
482 | if err != nil {
483 | return err
484 | }
485 | var val string
486 | for i := 0; i < n; i++ {
487 | val, err = ReadString(r)
488 | if err != nil {
489 | return err
490 | }
491 | msg.Argv = append(msg.Argv, val)
492 | }
493 | }
494 | return nil
495 | }
496 | func (msg *KeyEvent) Read(r *bufio.Reader) error {
497 | var err error
498 | err = err
499 | msg.Cell, err = ReadInt(r)
500 | if err != nil {
501 | return err
502 | }
503 | msg.Keys, err = ReadString(r)
504 | if err != nil {
505 | return err
506 | }
507 | return nil
508 | }
509 | func (msg *RowSpans) Read(r *bufio.Reader) error {
510 | var err error
511 | err = err
512 | msg.Row, err = ReadInt(r)
513 | if err != nil {
514 | return err
515 | }
516 | {
517 | n, err := ReadInt(r)
518 | if err != nil {
519 | return err
520 | }
521 | var val Span
522 | for i := 0; i < n; i++ {
523 | if err := val.Read(r); err != nil {
524 | return err
525 | }
526 | msg.Spans = append(msg.Spans, val)
527 | }
528 | }
529 | return nil
530 | }
531 | func (msg *Span) Read(r *bufio.Reader) error {
532 | var err error
533 | err = err
534 | msg.Attr, err = ReadInt(r)
535 | if err != nil {
536 | return err
537 | }
538 | msg.Text, err = ReadString(r)
539 | if err != nil {
540 | return err
541 | }
542 | return nil
543 | }
544 | func (msg *Cursor) Read(r *bufio.Reader) error {
545 | var err error
546 | err = err
547 | msg.Row, err = ReadInt(r)
548 | if err != nil {
549 | return err
550 | }
551 | msg.Col, err = ReadInt(r)
552 | if err != nil {
553 | return err
554 | }
555 | msg.Hidden, err = ReadBoolean(r)
556 | if err != nil {
557 | return err
558 | }
559 | return nil
560 | }
561 | func (msg *TermUpdate) Read(r *bufio.Reader) error {
562 | var err error
563 | err = err
564 | {
565 | n, err := ReadInt(r)
566 | if err != nil {
567 | return err
568 | }
569 | var val RowSpans
570 | for i := 0; i < n; i++ {
571 | if err := val.Read(r); err != nil {
572 | return err
573 | }
574 | msg.Rows = append(msg.Rows, val)
575 | }
576 | }
577 | if err := msg.Cursor.Read(r); err != nil {
578 | return err
579 | }
580 | msg.RowCount, err = ReadInt(r)
581 | if err != nil {
582 | return err
583 | }
584 | return nil
585 | }
586 | func (msg *Pair) Read(r *bufio.Reader) error {
587 | var err error
588 | err = err
589 | msg.Key, err = ReadString(r)
590 | if err != nil {
591 | return err
592 | }
593 | msg.Val, err = ReadString(r)
594 | if err != nil {
595 | return err
596 | }
597 | return nil
598 | }
599 | func (msg *Hello) Read(r *bufio.Reader) error {
600 | var err error
601 | err = err
602 | {
603 | n, err := ReadInt(r)
604 | if err != nil {
605 | return err
606 | }
607 | var val Pair
608 | for i := 0; i < n; i++ {
609 | if err := val.Read(r); err != nil {
610 | return err
611 | }
612 | msg.Alias = append(msg.Alias, val)
613 | }
614 | }
615 | {
616 | n, err := ReadInt(r)
617 | if err != nil {
618 | return err
619 | }
620 | var val Pair
621 | for i := 0; i < n; i++ {
622 | if err := val.Read(r); err != nil {
623 | return err
624 | }
625 | msg.Env = append(msg.Env, val)
626 | }
627 | }
628 | return nil
629 | }
630 | func (msg *CmdError) Read(r *bufio.Reader) error {
631 | var err error
632 | err = err
633 | msg.Error, err = ReadString(r)
634 | if err != nil {
635 | return err
636 | }
637 | return nil
638 | }
639 | func (msg *Exit) Read(r *bufio.Reader) error {
640 | var err error
641 | err = err
642 | msg.ExitCode, err = ReadInt(r)
643 | if err != nil {
644 | return err
645 | }
646 | return nil
647 | }
648 | func (msg *Output) Read(r *bufio.Reader) error {
649 | alt, err := r.ReadByte()
650 | if err != nil {
651 | return err
652 | }
653 | switch alt {
654 | case 1:
655 | var val CmdError
656 | if err := val.Read(r); err != nil {
657 | return err
658 | }
659 | msg.Alt = &val
660 | return nil
661 | case 2:
662 | var val TermUpdate
663 | if err := val.Read(r); err != nil {
664 | return err
665 | }
666 | msg.Alt = &val
667 | return nil
668 | case 3:
669 | var val Exit
670 | if err := val.Read(r); err != nil {
671 | return err
672 | }
673 | msg.Alt = &val
674 | return nil
675 | default:
676 | return fmt.Errorf("bad tag %d when reading Output", alt)
677 | }
678 | }
679 | func (msg *CellOutput) Read(r *bufio.Reader) error {
680 | var err error
681 | err = err
682 | msg.Cell, err = ReadInt(r)
683 | if err != nil {
684 | return err
685 | }
686 | if err := msg.Output.Read(r); err != nil {
687 | return err
688 | }
689 | return nil
690 | }
691 | func (msg *ServerMsg) Read(r *bufio.Reader) error {
692 | alt, err := r.ReadByte()
693 | if err != nil {
694 | return err
695 | }
696 | switch alt {
697 | case 1:
698 | var val Hello
699 | if err := val.Read(r); err != nil {
700 | return err
701 | }
702 | msg.Alt = &val
703 | return nil
704 | case 2:
705 | var val CompleteResponse
706 | if err := val.Read(r); err != nil {
707 | return err
708 | }
709 | msg.Alt = &val
710 | return nil
711 | case 3:
712 | var val CellOutput
713 | if err := val.Read(r); err != nil {
714 | return err
715 | }
716 | msg.Alt = &val
717 | return nil
718 | default:
719 | return fmt.Errorf("bad tag %d when reading ServerMsg", alt)
720 | }
721 | }
722 |
--------------------------------------------------------------------------------
/proto/gen.ts:
--------------------------------------------------------------------------------
1 | import * as ts from 'typescript';
2 | import * as fs from 'fs';
3 |
4 | class UnhandledError extends Error {
5 | constructor(readonly diag: ts.Diagnostic) {
6 | super('unhandled');
7 | }
8 | }
9 |
10 | function unhandled(node: ts.Node): never {
11 | const start = node.getStart();
12 | const end = node.getEnd();
13 | const diag: ts.Diagnostic = {
14 | start: node.pos,
15 | length: end - start,
16 | file: undefined, // will be filled in by 'catch'er
17 | code: 0,
18 | category: ts.DiagnosticCategory.Error,
19 | messageText: `unhandled ${ts.SyntaxKind[node.kind]}`,
20 | };
21 | throw new UnhandledError(diag);
22 | }
23 |
24 | function cap(name: string) {
25 | return name.substring(0, 1).toUpperCase() + name.substring(1);
26 | }
27 |
28 | function genGo(decls: proto.Named[], write: (out: string) => void) {
29 | const unions = new Set();
30 |
31 | write(`package proto
32 |
33 | import (
34 | "fmt"
35 | "io"
36 | "bufio"
37 | )
38 |
39 | type Msg interface{
40 | Write(w io.Writer) error
41 | Read(r *bufio.Reader) error
42 | }
43 |
44 | func ReadBoolean(r *bufio.Reader) (bool, error) {
45 | val, err := ReadUint8(r)
46 | if err != nil { return false, err }
47 | return val == 1, nil
48 | }
49 |
50 | func ReadUint8(r *bufio.Reader) (byte, error) {
51 | return r.ReadByte()
52 | }
53 |
54 | func ReadInt(r *bufio.Reader) (int, error) {
55 | shift := 0
56 | var val int
57 | for {
58 | b, err := r.ReadByte()
59 | if err != nil { return 0, err }
60 | val |= int(b & 0b0111_1111) << shift
61 | if (b & 0b1000_0000) == 0 {
62 | return val, nil
63 | }
64 | shift = shift + 7
65 | }
66 | }
67 |
68 | func ReadString(r *bufio.Reader) (string, error) {
69 | n, err := ReadInt(r)
70 | if err != nil { return "", err }
71 | buf := make([]byte, n)
72 | _, err = io.ReadFull(r, buf)
73 | if err != nil { return "", err }
74 | return string(buf), nil
75 | }
76 |
77 | func WriteBoolean(w io.Writer, val bool) error {
78 | if val {
79 | return WriteUint8(w, 1)
80 | } else {
81 | return WriteUint8(w, 0)
82 | }
83 | }
84 | func WriteUint8(w io.Writer, val byte) error {
85 | buf := [1]byte{val}
86 | _, err := w.Write(buf[:])
87 | return err
88 | }
89 | func WriteInt(w io.Writer, val int) error {
90 | if val < 0 { panic("negative") }
91 | for {
92 | b := byte(val & 0b0111_1111)
93 | val = val >> 7
94 | if val == 0 {
95 | return WriteUint8(w, b)
96 | } else {
97 | if err := WriteUint8(w, b | 0b1000_0000); err != nil { return err }
98 | }
99 | }
100 | }
101 | func WriteString(w io.Writer, str string) error {
102 | if len(str) >= 1<<16 {
103 | panic("overlong")
104 | }
105 | if err := WriteInt(w, len(str)); err != nil { return err }
106 | _, err := w.Write([]byte(str))
107 | return err
108 | }
109 | `);
110 | for (const { name, type } of decls) {
111 | switch (type.kind) {
112 | case 'union':
113 | write(`type ${name} struct {\n`);
114 | write(`// ${type.types.map((t) => t.type).join(', ')}\n`);
115 | write(`Alt Msg\n`);
116 | write(`}\n`);
117 | unions.add(name);
118 | break;
119 | case 'struct':
120 | write(`type ${name} struct {\n`);
121 | for (const f of type.fields) {
122 | write(`${cap(f.name)} ${typeToGo(f.type)}\n`);
123 | }
124 | write(`}\n`);
125 | break;
126 | default:
127 | throw new Error(`todo: ${JSON.stringify(type)}`);
128 | }
129 | }
130 |
131 | for (const { name, type } of decls) {
132 | write(`func (msg *${name}) Write(w io.Writer) error {\n`);
133 | switch (type.kind) {
134 | case 'union':
135 | write(`switch alt := msg.Alt.(type) {\n`);
136 | type.types.forEach((t, i) => {
137 | write(`case *${t.type}:\n`);
138 | write(
139 | `if err := WriteUint8(w, ${i + 1}); err != nil { return err }\n`
140 | );
141 | write(`return alt.Write(w)\n`);
142 | });
143 | write(`}\n`);
144 | write(`panic("notimpl")\n`);
145 | break;
146 | case 'struct':
147 | for (const f of type.fields) {
148 | writeValue(f.type, `msg.${cap(f.name)}`);
149 | }
150 | write(`return nil\n`);
151 | break;
152 | default:
153 | throw new Error(`todo: ${JSON.stringify(type)}`);
154 | }
155 | write(`}\n`);
156 | }
157 |
158 | for (const { name, type } of decls) {
159 | write(`func (msg *${name}) Read(r *bufio.Reader) error {\n`);
160 | switch (type.kind) {
161 | case 'union':
162 | write(`alt, err := r.ReadByte()\n`);
163 | write(`if err != nil { return err }\n`);
164 | write(`switch alt {\n`);
165 | type.types.forEach((t, i) => {
166 | write(`case ${i + 1}:\n`);
167 | write(`var val ${t.type}\n`);
168 | write(`if err := val.Read(r); err != nil { return err }\n`);
169 | write(`msg.Alt = &val\n`);
170 | write(`return nil\n`);
171 | });
172 | write(
173 | `default: return fmt.Errorf("bad tag %d when reading ${name}", alt)`
174 | );
175 | write(`}\n`);
176 | break;
177 | case 'struct':
178 | write(`var err error\n`);
179 | write(`err = err\n`);
180 | for (const field of type.fields) {
181 | readValue(field.type, `msg.${cap(field.name)}`);
182 | }
183 | write(`return nil\n`);
184 | break;
185 | default:
186 | write(`panic("notimpl")\n`);
187 | }
188 | write(`}\n`);
189 | }
190 |
191 | function readValue(type: proto.Type, name: string) {
192 | let fn = `${name}.Read`;
193 | switch (type.kind) {
194 | case 'ref':
195 | switch (type.type) {
196 | case 'boolean':
197 | case 'uint8':
198 | case 'int':
199 | case 'string':
200 | write(`${name}, err = Read${cap(type.type)}(r)\n`);
201 | write(`if err != nil { return err }\n`)
202 | return;
203 | }
204 | break;
205 | case 'array':
206 | write(`{\n`);
207 | write(`n, err := ReadInt(r)\n`);
208 | write(`if err != nil { return err }\n`);
209 | write(`var val ${typeToGo(type.type)}\n`)
210 | write(`for i := 0; i < n; i++ {\n`);
211 | readValue(type.type, 'val');
212 | write(`${name} = append(${name}, val)\n`)
213 | write(`}\n`);
214 | write(`}\n`);
215 | return;
216 | }
217 | write(`if err := ${fn}(r); err != nil { return err }\n`)
218 | }
219 |
220 | function writeValue(type: proto.Type, name: string) {
221 | switch (type.kind) {
222 | case 'ref':
223 | let fn = `Write${cap(type.type)}`;
224 | switch (type.type) {
225 | case 'boolean':
226 | case 'uint8':
227 | case 'int':
228 | case 'string':
229 | write(`if err := ${fn}(w, ${name}); err != nil { return err }\n`);
230 | return;
231 | }
232 | break;
233 | case 'array':
234 | write(
235 | `if err := WriteInt(w, len(${name})); err != nil { return err }\n`
236 | );
237 | write(`for _, val := range ${name} {\n`);
238 | writeValue(type.type, 'val');
239 | write(`}\n`);
240 | return;
241 | }
242 | write(`if err := ${name}.Write(w); err != nil { return err }\n`);
243 | }
244 |
245 | function refToGo(ref: proto.Ref): string {
246 | switch (ref.type) {
247 | case 'boolean':
248 | return 'bool';
249 | default:
250 | return ref.type;
251 | }
252 | }
253 | function typeToGo(type: proto.Type): string {
254 | switch (type.kind) {
255 | case 'ref':
256 | return refToGo(type);
257 | case 'array':
258 | return `[]${typeToGo(type.type)}`;
259 | default:
260 | throw new Error(`todo: ${JSON.stringify(type)}`);
261 | }
262 | }
263 | }
264 |
265 | function genTS(decls: proto.Named[], write: (out: string) => void) {
266 | write(`
267 | export type uint8 = number;
268 | `);
269 | for (const { name, type } of decls) {
270 | switch (type.kind) {
271 | case 'union':
272 | write(`export type ${name} = ${typeStr(type)};\n`);
273 | break;
274 | case 'struct':
275 | write(`export interface ${name} {\n`);
276 | for (const { name, type: fType } of type.fields) {
277 | write(`${name}: ${typeStr(fType)};\n`);
278 | }
279 | write(`}\n`);
280 | break;
281 | default:
282 | throw new Error(`todo: ${JSON.stringify(type)}`);
283 | }
284 | }
285 |
286 | write(`export class Reader {
287 | private ofs = 0;
288 | constructor(readonly view: DataView) {}
289 |
290 | private readUint8(): number {
291 | return this.view.getUint8(this.ofs++);
292 | }
293 |
294 | private readInt(): number {
295 | let val = 0;
296 | let shift = 0;
297 | for (;;) {
298 | const b = this.readUint8();
299 | val |= (b & 0x7f) << shift;
300 | if ((b & 0x80) === 0) break;
301 | shift += 7;
302 | }
303 | return val;
304 | }
305 |
306 | private readBoolean(): boolean {
307 | return this.readUint8() !== 0;
308 | }
309 |
310 | private readBytes(): DataView {
311 | const len = this.readInt();
312 | const slice = new DataView(this.view.buffer, this.ofs, len);
313 | this.ofs += len;
314 | return slice;
315 | }
316 |
317 | private readString(): string {
318 | const bytes = this.readBytes();
319 | return new TextDecoder().decode(bytes);
320 | }
321 |
322 | private readArray(elem: () => T): T[] {
323 | const len = this.readInt();
324 | const arr: T[] = new Array(len);
325 | for (let i = 0; i < len; i++) {
326 | arr[i] = elem();
327 | }
328 | return arr;
329 | }
330 | `);
331 | for (const { name, type } of decls) {
332 | write(`read${name}(): ${name} {\n`);
333 | switch (type.kind) {
334 | case 'union':
335 | write(`switch (this.readUint8()) {\n`);
336 | type.types.forEach((t, i) => {
337 | write(`case ${i + 1}: return {tag:'${typeStr(t)}', val:${readRef(t)}};\n`);
338 | });
339 | write(`default: throw new Error('parse error');`);
340 | write(`}\n`);
341 | break;
342 | case 'struct':
343 | write(`return {\n`);
344 | for (const field of type.fields) {
345 | write(`${field.name}: `);
346 | switch (field.type.kind) {
347 | case 'ref':
348 | write(readRef(field.type));
349 | break;
350 | case 'array':
351 | write(`this.readArray(() => ${readRef(field.type.type)})`);
352 | break;
353 | default:
354 | throw new Error(`todo: ${JSON.stringify(field.type)}`);
355 | }
356 | write(`,\n`);
357 | }
358 | write(`};\n`);
359 | break;
360 | default:
361 | throw new Error(`todo: ${JSON.stringify(type)}`);
362 | }
363 | write(`}\n`);
364 | }
365 | write(`}\n`);
366 |
367 | write(`export class Writer {
368 | public ofs = 0;
369 | public buf = new Uint8Array();
370 | writeBoolean(val: boolean) {
371 | this.writeUint8(val ? 1 : 0);
372 | }
373 | writeUint8(val: number) {
374 | if (val > 0xFF) throw new Error('overflow');
375 | this.buf[this.ofs++] = val;
376 | }
377 | writeInt(val: number) {
378 | if (val < 0) throw new Error('negative');
379 | for (;;) {
380 | const b = val & 0x7f;
381 | val = val >> 7;
382 | if (val === 0) {
383 | this.writeUint8(b);
384 | return;
385 | }
386 | this.writeUint8(b | 0x80);
387 | }
388 | }
389 | writeString(str: string) {
390 | this.writeInt(str.length);
391 | for (let i = 0; i < str.length; i++) {
392 | this.buf[this.ofs++] = str.charCodeAt(i);
393 | }
394 | }
395 | writeArray(arr: T[], f: (t: T) => void) {
396 | this.writeInt(arr.length);
397 | for (const elem of arr) {
398 | f(elem);
399 | }
400 | }
401 | `);
402 | for (const { name, type } of decls) {
403 | write(`write${name}(msg: ${name}) {\n`);
404 | switch (type.kind) {
405 | case 'union':
406 | write(`switch (msg.tag) {\n`);
407 | type.types.forEach((t, i) => {
408 | write(`case '${typeStr(t)}':\n`);
409 | i;
410 | write(`this.writeUint8(${i + 1});\n`);
411 | write(`this.write${t.type}(msg.val);\n`);
412 | write(`break;\n`);
413 | });
414 | write(`}\n`);
415 | break;
416 | case 'struct':
417 | for (const field of type.fields) {
418 | switch (field.type.kind) {
419 | case 'ref':
420 | write(`${writeRef(field.type)}(msg.${field.name});\n`);
421 | break;
422 | case 'array':
423 | write(
424 | `this.writeArray(msg.${field.name}, (val) => { ${writeRef(
425 | field.type.type
426 | )}(val); });\n`
427 | );
428 | break;
429 | default:
430 | console.error(field);
431 | throw new Error('f');
432 | }
433 | }
434 | break;
435 | default:
436 | throw new Error(`todo: ${JSON.stringify(type)}`);
437 | }
438 | write(`}\n`);
439 | }
440 | write(`}\n`);
441 |
442 | function readRef(type: proto.Ref): string {
443 | return `this.read${cap(type.type)}()`;
444 | }
445 |
446 | function writeRef(type: proto.Ref): string {
447 | return `this.write${cap(type.type)}`;
448 | }
449 |
450 | function refStr(type: proto.Ref): string {
451 | switch (type.type) {
452 | case 'uint8':
453 | case 'int':
454 | return 'number';
455 | case 'boolean':
456 | case 'string':
457 | default:
458 | return type.type;
459 | }
460 | }
461 | function typeStr(type: proto.Type): string {
462 | // Intentionally not recursive -- we don't want complex nested types.
463 | switch (type.kind) {
464 | case 'ref':
465 | return refStr(type);
466 | case 'array':
467 | return `${refStr(type.type)}[]`;
468 | case 'union':
469 | return type.types.map(t => {
470 | const name = refStr(t);
471 | return `{tag:'${name}',val:${name}}`;
472 | }).join('|');
473 | default:
474 | throw new Error(`disallowed: ${JSON.stringify(type)}`);
475 | }
476 | }
477 | }
478 |
479 | namespace proto {
480 | export type Type = Ref | Array | Union | Struct;
481 | export interface Named {
482 | name: string;
483 | type: Type;
484 | }
485 | export interface Ref {
486 | kind: 'ref';
487 | type: string;
488 | }
489 | export interface Array {
490 | kind: 'array';
491 | type: Ref;
492 | }
493 | export interface Union {
494 | kind: 'union';
495 | types: Ref[];
496 | }
497 | export interface Struct {
498 | kind: 'struct';
499 | fields: Named[];
500 | }
501 | }
502 |
503 | function parse(sourceFile: ts.SourceFile): proto.Named[] {
504 | const decls: proto.Named[] = [];
505 | for (const stmt of sourceFile.statements) {
506 | if (ts.isTypeAliasDeclaration(stmt)) {
507 | const name = stmt.name.text;
508 | const type = parseType(stmt.type);
509 | if (type.kind === 'ref') continue;
510 | decls.push({ name, type });
511 | } else if (ts.isInterfaceDeclaration(stmt)) {
512 | const name = stmt.name.text;
513 | const fields = stmt.members.map((field) => {
514 | if (!field.name) unhandled(stmt);
515 | if (!ts.isIdentifier(field.name)) unhandled(field.name);
516 | const name = field.name.text;
517 | if (!ts.isPropertySignature(field)) unhandled(field);
518 | if (!field.type) unhandled(field);
519 | return { name, type: parseType(field.type) };
520 | });
521 | decls.push({ name, type: { kind: 'struct', fields } });
522 | } else {
523 | unhandled(stmt);
524 | }
525 | }
526 | return decls;
527 |
528 | function parseType(type: ts.TypeNode): proto.Type {
529 | if (type.kind === ts.SyntaxKind.NumberKeyword) {
530 | return { kind: 'ref', type: 'number' };
531 | } else if (type.kind === ts.SyntaxKind.StringKeyword) {
532 | return { kind: 'ref', type: 'string' };
533 | } else if (type.kind === ts.SyntaxKind.BooleanKeyword) {
534 | return { kind: 'ref', type: 'boolean' };
535 | } else if (ts.isTypeReferenceNode(type)) {
536 | if (!ts.isIdentifier(type.typeName)) unhandled(type.typeName);
537 | return { kind: 'ref', type: type.typeName.text };
538 | } else if (ts.isArrayTypeNode(type)) {
539 | const inner = parseType(type.elementType);
540 | if (inner.kind !== 'ref') unhandled(type.elementType);
541 | return { kind: 'array', type: inner };
542 | } else if (ts.isUnionTypeNode(type)) {
543 | const union = type.types.map((t) => {
544 | const inner = parseType(t);
545 | if (inner.kind !== 'ref') unhandled(t);
546 | return inner;
547 | });
548 | return { kind: 'union', types: union };
549 | } else {
550 | unhandled(type);
551 | }
552 | }
553 | }
554 |
555 | function usage() {
556 | console.error('usage: gen ts|go input.d.ts > output');
557 | }
558 |
559 | function main(args: string[]): number {
560 | const [mode, fileName] = args;
561 |
562 | if (!fileName) {
563 | usage();
564 | return 1;
565 | }
566 |
567 | const sourceText = fs.readFileSync(fileName, 'utf8');
568 | const sourceFile = ts.createSourceFile(
569 | fileName,
570 | sourceText,
571 | ts.ScriptTarget.Latest,
572 | true
573 | );
574 |
575 | let decls: proto.Named[];
576 | try {
577 | decls = parse(sourceFile);
578 | } catch (err) {
579 | if (err instanceof UnhandledError) {
580 | err.diag.file = sourceFile;
581 | const host: ts.FormatDiagnosticsHost = {
582 | getCurrentDirectory: () => process.cwd(),
583 | getCanonicalFileName: (fileName: string) => fileName,
584 | getNewLine: () => '\n',
585 | };
586 | console.error(ts.formatDiagnosticsWithColorAndContext([err.diag], host));
587 | return 1;
588 | }
589 | throw err;
590 | }
591 |
592 | if (mode === 'ts') {
593 | genTS(decls, (text) => process.stdout.write(text));
594 | } else if (mode == 'go') {
595 | genGo(decls, (text) => process.stdout.write(text));
596 | } else {
597 | usage();
598 | return 1;
599 | }
600 |
601 | return 0;
602 | }
603 |
604 | process.exitCode = main(process.argv.slice(2));
605 |
--------------------------------------------------------------------------------
/cli/vt100/terminal.go:
--------------------------------------------------------------------------------
1 | package vt100
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "fmt"
7 | "io"
8 | "io/ioutil"
9 | "log"
10 | "strings"
11 | "unicode/utf8"
12 | )
13 |
14 | // Bits is a uint16 with some bitfield accessors.
15 | type Bits uint16
16 |
17 | func (b Bits) Get(ofs uint, count uint) uint16 {
18 | return (uint16(b) >> ofs) & (uint16(1< 8 {
72 | return fmt.Errorf("%s: bad color", a)
73 | }
74 | if c := a.BackColor(); c < 0 || c > 8 {
75 | return fmt.Errorf("%s: bad back color", a)
76 | }
77 | if uint16(a)&0xFC00 != 0 {
78 | return fmt.Errorf("%s: extra bits", a)
79 | }
80 | return nil
81 | }
82 |
83 | func (a Attr) String() string {
84 | fields := []string{}
85 | if a.Inverse() {
86 | fields = append(fields, "inverse")
87 | }
88 | if a.Bright() {
89 | fields = append(fields, "bright")
90 | }
91 | if fg := a.Color(); fg != 0 {
92 | fields = append(fields, fmt.Sprintf("fg:%d", fg))
93 | }
94 | if bg := a.BackColor(); bg != 0 {
95 | fields = append(fields, fmt.Sprintf("bg:%d", bg))
96 | }
97 | return fmt.Sprintf("Attr{%s}", strings.Join(fields, ","))
98 | }
99 |
100 | func showChar(ch byte) string {
101 | if ch >= ' ' && ch <= '~' {
102 | return fmt.Sprintf("'%c'", ch)
103 | } else {
104 | return fmt.Sprintf("%#x", ch)
105 | }
106 | }
107 |
108 | // Cell is a single character cell in the rendered terminal.
109 | type Cell struct {
110 | Ch rune
111 | Attr Attr
112 | }
113 |
114 | func (c Cell) String() string {
115 | return fmt.Sprintf("Cell{%q, %s}", c.Ch, c.Attr)
116 | }
117 |
118 | // FeatureLog records missing terminal features as TODOs.
119 | type FeatureLog map[string]int
120 |
121 | func (f FeatureLog) Add(text string, args ...interface{}) {
122 | if _, known := f[text]; !known {
123 | log.Printf("vt100 TODO: "+text, args...)
124 | }
125 | f[text]++
126 | }
127 |
128 | // Terminal is the rendered state of a terminal after vt100 emulation.
129 | type Terminal struct {
130 | Title string
131 | Lines [][]Cell
132 | Width int
133 | Height int
134 | HideCursor bool
135 |
136 | // CanScroll is true when accumulating scrollback is allowed,
137 | // false when using the "alternative screen" where scrolling off the bottom
138 | // should erase lines from the top.
139 | CanScroll bool
140 |
141 | // Index of first displayed line; greater than 0 when content has
142 | // scrolled off the top of the terminal.
143 | Top int
144 |
145 | // The 0-based position of the cursor.
146 | Row, Col int
147 |
148 | // Saved versions of Row/Col for the control sequence that saves/restores position.
149 | SaveRow, SaveCol int
150 | }
151 |
152 | func NewTerminal() *Terminal {
153 | return &Terminal{
154 | Lines: make([][]Cell, 1),
155 | Width: 80,
156 | Height: 24,
157 | CanScroll: true,
158 | }
159 | }
160 |
161 | // fixPosition ensures that terminal offsets (Top/Row/Height) always
162 | // refer to valid places within the Terminal Lines array.
163 | func (t *Terminal) fixPosition(dirty *TermDirty) {
164 | if t.Row >= t.Top+t.Height {
165 | if t.CanScroll {
166 | t.Top++
167 | } else {
168 | scroll := t.Row - t.Height + 1
169 | t.Lines = t.Lines[scroll:]
170 | t.Row -= scroll
171 | dirty.Lines[-1] = true // Rerender all lines.
172 | }
173 | }
174 | for t.Row >= len(t.Lines) {
175 | t.Lines = append(t.Lines, make([]Cell, 0))
176 | }
177 | for t.Col > len(t.Lines[t.Row]) {
178 | t.Lines[t.Row] = append(t.Lines[t.Row], Cell{' ', 0})
179 | }
180 | }
181 |
182 | type TermDirty struct {
183 | Cursor bool
184 | Lines map[int]bool
185 | }
186 |
187 | func (t *TermDirty) IsDirty() bool {
188 | return t.Cursor || len(t.Lines) > 0
189 | }
190 |
191 | func (t *TermDirty) Reset() {
192 | t.Lines = make(map[int]bool)
193 | t.Cursor = false
194 | }
195 |
196 | // TermReader carries in-progress terminal state during vt100 emulation.
197 | // It passes updates to its output to an underlying Terminal.
198 | // It's split from Terminal because it can run concurrently with a Terminal.
199 | type TermReader struct {
200 | WithTerm func(func(t *Terminal))
201 |
202 | Dirty TermDirty
203 | Input io.Writer
204 | TODOs FeatureLog
205 |
206 | // The current display attributes, used for the next written character.
207 | Attr Attr
208 | }
209 |
210 | func NewTermReader(withTerm func(func(t *Terminal))) *TermReader {
211 | return &TermReader{
212 | Dirty: TermDirty{Lines: make(map[int]bool)},
213 | WithTerm: withTerm,
214 | TODOs: FeatureLog{},
215 | Input: ioutil.Discard,
216 | }
217 | }
218 |
219 | func (tr *TermReader) Read(r *bufio.Reader) error {
220 | c, err := r.ReadByte()
221 | if err != nil {
222 | return err
223 | }
224 | switch {
225 | case c == 0x7: // bell
226 | // ignore
227 | case c == 0x8: // backspace
228 | tr.WithTerm(func(t *Terminal) {
229 | if t.Col > 0 {
230 | t.Col--
231 | }
232 | tr.Dirty.Cursor = true
233 | })
234 | case c == 0xf: // exit_alt_charset_mode under screen(?)
235 | // ignore
236 | case c == 0x1b:
237 | return tr.readEscape(r)
238 | case c == '\r':
239 | tr.WithTerm(func(t *Terminal) {
240 | t.Col = 0
241 | tr.Dirty.Cursor = true
242 | })
243 | case c == '\n':
244 | tr.WithTerm(func(t *Terminal) {
245 | t.Col = 0
246 | t.Row++
247 | t.fixPosition(&tr.Dirty)
248 | tr.Dirty.Cursor = true
249 | })
250 | case c == '\t':
251 | tr.WithTerm(func(t *Terminal) {
252 | t.Col += 8 - (t.Col % 8)
253 | t.fixPosition(&tr.Dirty)
254 | tr.Dirty.Cursor = true
255 | })
256 | case c >= ' ' && c <= '~':
257 | // Plain text. Peek ahead to read a block of text together.
258 | // This lets writeRunes batch its modification.
259 | r.UnreadByte()
260 | max := r.Buffered()
261 | var buf [80]rune
262 | if max > 80 {
263 | max = 80
264 | }
265 | for i := 0; i < max; i++ {
266 | // Ignore error because we're reading from the buffer.
267 | c, _ := r.ReadByte()
268 | if c < ' ' || c > '~' {
269 | r.UnreadByte()
270 | max = i
271 | }
272 | buf[i] = rune(c)
273 | }
274 | tr.writeRunes(buf[:max], tr.Attr)
275 | default:
276 | r.UnreadByte()
277 | return tr.readUTF8(r)
278 | }
279 | return nil
280 | }
281 |
282 | func (t *Terminal) writeRune(dirty *TermDirty, r rune, attr Attr) {
283 | if t.Col == t.Width {
284 | t.Row++
285 | t.Col = 0
286 | }
287 | t.Col++
288 | t.fixPosition(dirty)
289 | t.Lines[t.Row][t.Col-1] = Cell{r, attr}
290 | dirty.Lines[t.Row] = true
291 | }
292 |
293 | func (tr *TermReader) writeRunes(rs []rune, attr Attr) {
294 | tr.WithTerm(func(t *Terminal) {
295 | for _, r := range rs {
296 | t.writeRune(&tr.Dirty, r, attr)
297 | }
298 | tr.Dirty.Cursor = true
299 | })
300 | }
301 |
302 | func (t *TermReader) readUTF8(r io.ByteScanner) error {
303 | c, err := r.ReadByte()
304 | if err != nil {
305 | return err
306 | }
307 |
308 | attr := t.Attr
309 |
310 | var uc rune
311 | n := 0
312 | switch {
313 | case c&0xE0 == 0xB0:
314 | uc = rune(c & 0x1F)
315 | n = 2
316 | case c&0xF0 == 0xE0:
317 | uc = rune(c & 0x0F)
318 | n = 3
319 | default:
320 | if c&0xF0 == 0xF0 {
321 | log.Printf("term: not yet implemented: utf8 start %#v", c)
322 | }
323 | attr.SetInverse(true)
324 | t.writeRunes([]rune{'@'}, attr)
325 | return nil
326 | }
327 |
328 | for i := 1; i < n; i++ {
329 | c, err := r.ReadByte()
330 | if err != nil {
331 | return err
332 | }
333 | if c&0xC0 != 0x80 {
334 | log.Printf("term: not yet implemented: utf8 continuation %#v", c)
335 | attr.SetInverse(true)
336 | uc = '@'
337 | break
338 | }
339 | uc = uc<<6 | rune(c&0x3F)
340 | }
341 | // TODO: read block of UTF here.
342 | t.writeRunes([]rune{uc}, attr)
343 | return nil
344 | }
345 |
346 | func (tr *TermReader) readEscape(r io.ByteScanner) error {
347 | // http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
348 | c, err := r.ReadByte()
349 | if err != nil {
350 | return err
351 | }
352 | switch {
353 | case c == '(':
354 | c, err := r.ReadByte()
355 | if err != nil {
356 | return err
357 | }
358 | switch c {
359 | case 'B': // US ASCII
360 | // ignore
361 | default:
362 | tr.TODOs.Add("g0 charset %s", showChar(c))
363 | }
364 | case c == '=':
365 | tr.TODOs.Add("application keypad")
366 | case c == '>':
367 | tr.TODOs.Add("normal keypad")
368 | case c == '[':
369 | return tr.readCSI(r)
370 | case c == ']':
371 | // OSC Ps ; Pt BEL
372 | n, err := tr.readInt(r)
373 | if err != nil {
374 | return err
375 | }
376 | _, err = tr.expect(r, ';')
377 | if err != nil {
378 | return err
379 | }
380 | text, err := tr.readTo(r, 0x7)
381 | if err != nil {
382 | return err
383 | }
384 | switch n {
385 | case 0, 1, 2:
386 | tr.WithTerm(func(t *Terminal) {
387 | t.Title = string(text)
388 | // TODO: tr.Dirty
389 | })
390 | case 10, 11, 12, 13, 14, 15, 16, 17, 18, 19: // dymamic colors
391 | if string(text) == "?" {
392 | tr.TODOs.Add("vt100 dynamic color query %d", n)
393 | } else {
394 | tr.TODOs.Add("vt100 dynamic color %d %q", n, text)
395 | }
396 | default:
397 | log.Printf("term: bad OSC %d %v", n, text)
398 | }
399 | case c == 'M': // move up/insert line
400 | tr.WithTerm(func(t *Terminal) {
401 | if t.Row == 0 {
402 | // Insert line above.
403 | if t.CanScroll {
404 | t.Lines = append(t.Lines, nil) // Extra space for line shifted down.
405 | }
406 | copy(t.Lines[1:], t.Lines)
407 | t.Lines[0] = make([]Cell, 0)
408 | } else {
409 | if t.Row == t.Top {
410 | t.Top--
411 | if len(t.Lines) > t.Top+t.Height {
412 | t.Lines = t.Lines[:t.Top+t.Height-1]
413 | }
414 | }
415 | t.Row--
416 | }
417 | tr.Dirty.Lines[-1] = true
418 | })
419 | case c == 'P': // device control string
420 | return tr.readDCS(r)
421 | case c == '7': // save cursor
422 | tr.WithTerm(func(t *Terminal) {
423 | t.SaveRow = t.Row
424 | t.SaveCol = t.Col
425 | })
426 | case c == '8': // restore cursor
427 | tr.WithTerm(func(t *Terminal) {
428 | t.Row = t.SaveRow
429 | t.Col = t.SaveCol
430 | })
431 | tr.Dirty.Cursor = true
432 | default:
433 | log.Printf("term: unknown escape %s", showChar(c))
434 | }
435 | return nil
436 | }
437 |
438 | func readArgs(args []int, values ...*int) {
439 | for i, val := range values {
440 | if i < len(args) {
441 | *val = args[i]
442 | }
443 | }
444 | }
445 |
446 | // mapColor converts a CSI color (e.g. 0=black, 1=red) to the term
447 | // representation (0=default, 1=black).
448 | func mapColor(color int, arg int) int {
449 | switch {
450 | case color == 8:
451 | log.Printf("term: bad color %d", arg)
452 | return 0
453 | case color == 9:
454 | return 0
455 | default:
456 | return color + 1
457 | }
458 | }
459 |
460 | // readCSI reads a CSI escape, which look like
461 | // \e[1;2x
462 | // where "1" and "2" are "arguments" to the "x" command.
463 | func (tr *TermReader) readCSI(r io.ByteScanner) error {
464 | var args []int
465 |
466 | qflag := false
467 | gtflag := false
468 | L:
469 | c, err := r.ReadByte()
470 | if err != nil {
471 | return err
472 | }
473 |
474 | switch {
475 | case c >= '0' && c <= '9':
476 | r.UnreadByte()
477 | n, err := tr.readInt(r)
478 | if err != nil {
479 | return err
480 | }
481 | args = append(args, n)
482 |
483 | c, err = r.ReadByte()
484 | if err != nil {
485 | return err
486 | }
487 | if c == ';' {
488 | goto L
489 | }
490 | case c == '?':
491 | qflag = true
492 | goto L
493 | case c == '>':
494 | gtflag = true
495 | goto L
496 | }
497 |
498 | switch {
499 | case c == '$': // request ansi/dec mode
500 | // Ends in "$p" for some reason.
501 | if _, err := tr.expect(r, 'p'); err != nil {
502 | return err
503 | }
504 | if !qflag {
505 | tr.TODOs.Add("request ANSI mode")
506 | } else {
507 | tr.TODOs.Add("request DEC private mode")
508 | }
509 | case c == '@': // insert blanks
510 | n := 1
511 | readArgs(args, &n)
512 | tr.WithTerm(func(t *Terminal) {
513 | for i := 0; i < n; i++ {
514 | t.Lines[t.Row] = append(t.Lines[t.Row], Cell{})
515 | }
516 | copy(t.Lines[t.Row][t.Col+n:], t.Lines[t.Row][t.Col:])
517 | for i := 0; i < n; i++ {
518 | t.Lines[t.Row][t.Col+i] = Cell{' ', 0}
519 | }
520 | tr.Dirty.Lines[t.Row] = true
521 | })
522 | case c == 'A': // cursor up
523 | dy := 1
524 | readArgs(args, &dy)
525 | tr.WithTerm(func(t *Terminal) {
526 | t.Row -= dy
527 | if t.Row < 0 {
528 | log.Printf("term: cursor up off top of screen?")
529 | t.Row = 0
530 | }
531 | t.fixPosition(&tr.Dirty)
532 | tr.Dirty.Cursor = true
533 | })
534 | case c == 'C': // cursor forward
535 | dx := 1
536 | readArgs(args, &dx)
537 | tr.WithTerm(func(t *Terminal) {
538 | t.Col += dx
539 | t.fixPosition(&tr.Dirty)
540 | tr.Dirty.Cursor = true
541 | })
542 | case c == 'D': // cursor back
543 | dx := 1
544 | readArgs(args, &dx)
545 | tr.WithTerm(func(t *Terminal) {
546 | t.Col -= dx
547 | t.fixPosition(&tr.Dirty)
548 | tr.Dirty.Cursor = true
549 | })
550 | case c == 'G': // cursor character absolute
551 | x := 1
552 | readArgs(args, &x)
553 | tr.WithTerm(func(t *Terminal) {
554 | t.Col = x - 1
555 | t.fixPosition(&tr.Dirty)
556 | tr.Dirty.Cursor = true
557 | })
558 | case c == 'H': // move to position
559 | row := 1
560 | col := 1
561 | readArgs(args, &row, &col)
562 | if row == 0 {
563 | row = 1
564 | }
565 | if col == 0 {
566 | col = 1
567 | }
568 | tr.WithTerm(func(t *Terminal) {
569 | t.Row = t.Top + row - 1
570 | t.Col = col - 1
571 | t.fixPosition(&tr.Dirty)
572 | tr.Dirty.Cursor = true
573 | })
574 | case c == 'J': // erase in display
575 | arg := 0
576 | readArgs(args, &arg)
577 | tr.WithTerm(func(t *Terminal) {
578 | switch arg {
579 | case 0: // erase to end
580 | for i := t.Row; i < len(t.Lines); i++ {
581 | tr.Dirty.Lines[i] = true
582 | }
583 | t.Lines = t.Lines[:t.Row+1]
584 | t.Lines[t.Row] = t.Lines[t.Row][:t.Col]
585 | case 2: // erase all
586 | t.Lines = t.Lines[:0]
587 | t.Row = 0
588 | t.Col = 0
589 | t.fixPosition(&tr.Dirty)
590 | tr.Dirty.Lines[-1] = true
591 | default:
592 | log.Printf("term: unknown erase in display %v", args)
593 | }
594 | })
595 | case c == 'K': // erase in line
596 | arg := 0
597 | readArgs(args, &arg)
598 | tr.WithTerm(func(t *Terminal) {
599 | switch arg {
600 | case 0: // erase to right
601 | t.Lines[t.Row] = t.Lines[t.Row][:t.Col]
602 | case 1:
603 | for i := 0; i < t.Col; i++ {
604 | t.Lines[t.Row][i] = Cell{' ', 0}
605 | }
606 | case 2:
607 | t.Lines[t.Row] = t.Lines[t.Row][0:0]
608 | default:
609 | log.Printf("term: unknown erase in line %v", args)
610 | }
611 | tr.Dirty.Lines[t.Row] = true
612 | })
613 | case c == 'L': // insert lines
614 | n := 1
615 | readArgs(args, &n)
616 | tr.WithTerm(func(t *Terminal) {
617 | for i := 0; i < n; i++ {
618 | t.Lines = append(t.Lines, nil)
619 | }
620 | copy(t.Lines[t.Row+n:], t.Lines[t.Row:])
621 | for i := 0; i < n; i++ {
622 | t.Lines[t.Row+i] = make([]Cell, 0)
623 | }
624 | tr.Dirty.Lines[-1] = true
625 | })
626 | case c == 'P': // erase in line
627 | arg := 1
628 | tr.WithTerm(func(t *Terminal) {
629 | readArgs(args, &arg)
630 | l := t.Lines[t.Row]
631 | if t.Col+arg > len(l) {
632 | arg = len(l) - t.Col
633 | }
634 | copy(l[t.Col:], l[t.Col+arg:])
635 | t.Lines[t.Row] = l[:len(l)-arg]
636 | tr.Dirty.Lines[t.Row] = true
637 | })
638 | case c == 'X': // erase characters
639 | tr.TODOs.Add("erase characters %v", args)
640 | case !gtflag && c == 'c': // send device attributes (primary)
641 | tr.TODOs.Add("send device attributes (primary) %v", args)
642 | case gtflag && c == 'c': // send device attributes (secondary)
643 | arg := 0
644 | readArgs(args, &arg)
645 | switch arg {
646 | case 0: // terminal id
647 | // ID is
648 | // 0 -> VT100
649 | // 0 -> firmware version 0
650 | // 0 -> always-zero param
651 | _, err := tr.Input.Write([]byte("\x1b[0;0;0c"))
652 | return err
653 | default:
654 | tr.TODOs.Add("send device attributes (secondary) %v", args)
655 | }
656 | case c == 'd': // line position
657 | arg := 1
658 | readArgs(args, &arg)
659 | tr.WithTerm(func(t *Terminal) {
660 | t.Row = arg - 1
661 | t.fixPosition(&tr.Dirty)
662 | tr.Dirty.Cursor = true
663 | })
664 | case !qflag && (c == 'h' || c == 'l'): // reset mode
665 | reset := c == 'l'
666 | arg := 0
667 | readArgs(args, &arg)
668 | switch arg {
669 | default:
670 | tr.TODOs.Add("reset mode %d %v", arg, reset)
671 | }
672 | case qflag && (c == 'h' || c == 'l'): // DEC private mode set/reset
673 | set := c == 'h'
674 | arg := 0
675 | readArgs(args, &arg)
676 | tr.WithTerm(func(t *Terminal) {
677 | switch arg {
678 | case 1:
679 | tr.TODOs.Add("application cursor keys mode")
680 | case 7: // wraparound mode
681 | tr.TODOs.Add("wraparound mode")
682 | case 12: // blinking cursor
683 | // Ignore; this appears in cnorm/cvvis as a way to adjust the
684 | // "very visible cursor" state.
685 | case 25: // show cursor
686 | t.HideCursor = !set
687 | tr.Dirty.Cursor = true
688 | case 1000, 1001, 1002: // mouse
689 | tr.TODOs.Add("mouse handling")
690 | case 1049: // alternate screen buffer
691 | // Rather than implementing an alternate screen, instead just clobber the content.
692 | // This is because we don't anticipate running a series of programs within a given term,
693 | // but rather just one.
694 | t.Lines = make([][]Cell, t.Height)
695 | t.Top = 0
696 | t.CanScroll = false
697 | tr.Dirty.Lines[-1] = true
698 | case 2004: // bracketed paste
699 | tr.TODOs.Add("bracketed paste")
700 | default:
701 | log.Printf("term: unknown dec private mode %v %v", args, set)
702 | }
703 | })
704 | case c == 'm': // character attributes
705 | if len(args) == 0 {
706 | args = append(args, 0)
707 | }
708 | for _, arg := range args {
709 | switch {
710 | case arg == 0:
711 | tr.Attr = 0
712 | case arg == 1:
713 | tr.Attr.SetBright(true)
714 | case arg == 2: // faint
715 | // ignore
716 | case arg == 7:
717 | tr.Attr.SetInverse(true)
718 | case arg == 22: // clear bold
719 | tr.Attr.SetBright(false)
720 | case arg == 23: // clear italics
721 | // ignore
722 | case arg == 29: // clear crossed-out
723 | // ignore
724 | case arg == 27:
725 | tr.Attr.SetInverse(false)
726 | case arg >= 30 && arg < 40:
727 | tr.Attr.SetColor(mapColor(arg-30, arg))
728 | case arg >= 40 && arg < 50:
729 | tr.Attr.SetBackColor(mapColor(arg-40, arg))
730 | case arg >= 90 && arg <= 97:
731 | tr.Attr.SetBright(true)
732 | tr.Attr.SetColor(mapColor(arg-90, arg))
733 | case arg >= 100 && arg <= 107:
734 | // TODO: set bright background?
735 | tr.Attr.SetBackColor(mapColor(arg-100, arg))
736 | default:
737 | log.Printf("term: unknown color %v", args)
738 | }
739 | }
740 | case gtflag && c == 'n': // disable modifiers
741 | arg := 2
742 | readArgs(args, &arg)
743 | tr.WithTerm(func(t *Terminal) {
744 | switch arg {
745 | case 0:
746 | tr.TODOs.Add("disable modify keyboard")
747 | case 1:
748 | tr.TODOs.Add("disable modify cursor keys")
749 | case 2:
750 | tr.TODOs.Add("disable modify function keys")
751 | case 4:
752 | tr.TODOs.Add("disable modify other keys")
753 | }
754 | })
755 | case c == 'n': // device status report
756 | arg := 0
757 | readArgs(args, &arg)
758 | switch arg {
759 | case 5:
760 | _, err := tr.Input.Write([]byte("\x1b[0n"))
761 | return err
762 | case 6:
763 | var pos string
764 | tr.WithTerm(func(t *Terminal) {
765 | pos = fmt.Sprintf("\x1b[%d;%dR", (t.Row-t.Top)+1, t.Col+1)
766 | })
767 | _, err := tr.Input.Write([]byte(pos))
768 | return err
769 | default:
770 | log.Printf("term: unknown status report arg %v", args)
771 | }
772 | case c == 'r': // set scrolling region
773 | top, bot := 1, 1
774 | readArgs(args, &top, &bot)
775 | tr.WithTerm(func(t *Terminal) {
776 | if top == 1 && bot == t.Height {
777 | // Just setting the current region as scroll.
778 | } else {
779 | tr.TODOs.Add("set scrolling region %v", args)
780 | }
781 | })
782 | case c == 't': // window manipulation
783 | cmd := 0
784 | readArgs(args, &cmd)
785 | switch cmd {
786 | case 22, 23:
787 | // Icon/title push/pop. Ignore.
788 | default:
789 | tr.TODOs.Add("window manip %v", args)
790 | }
791 | default:
792 | log.Printf("term: unknown CSI %v %s", args, showChar(c))
793 | }
794 | return nil
795 | }
796 |
797 | func (t *TermReader) readDCS(r io.ByteScanner) error {
798 | c, err := r.ReadByte()
799 | if err != nil {
800 | return err
801 | }
802 | switch c {
803 | case '+':
804 | c, err := r.ReadByte()
805 | if err != nil {
806 | return err
807 | }
808 | switch c {
809 | case 'q': // request termcap/terminfo string
810 | buf := bytes.Buffer{}
811 | L:
812 | for {
813 | c, err := r.ReadByte()
814 | if err != nil {
815 | return err
816 | }
817 | switch c {
818 | case '\x1b':
819 | // String is terminated by the sequence
820 | // ESC \
821 | if _, err := t.expect(r, '\\'); err != nil {
822 | return err
823 | }
824 | break L
825 | default:
826 | buf.WriteByte(c)
827 | }
828 | }
829 | t.TODOs.Add("request termcap string %q", buf.String())
830 | default:
831 | log.Printf("term: unknown DCS +%c", c)
832 | }
833 | }
834 | return nil
835 | }
836 |
837 | func (t *TermReader) expect(r io.ByteScanner, exp byte) (bool, error) {
838 | c, err := r.ReadByte()
839 | if err != nil {
840 | return false, err
841 | }
842 | ok := c == exp
843 | if !ok {
844 | log.Printf("expect %s failed, got %s", showChar(exp), showChar(c))
845 | }
846 | return ok, nil
847 | }
848 |
849 | func (t *TermReader) readInt(r io.ByteScanner) (int, error) {
850 | n := 0
851 | for i := 0; i < 20; i++ {
852 | c, err := r.ReadByte()
853 | if err != nil {
854 | return -1, err
855 | }
856 | if c >= '0' && c <= '9' {
857 | n = n*10 + int(c) - '0'
858 | } else {
859 | r.UnreadByte()
860 | return n, err
861 | }
862 | }
863 | return -1, fmt.Errorf("term: readInt overlong")
864 | }
865 |
866 | func (t *TermReader) readTo(r io.ByteScanner, end byte) ([]byte, error) {
867 | var buf []byte
868 | for i := 0; i < 1000; i++ {
869 | c, err := r.ReadByte()
870 | if err != nil {
871 | return nil, err
872 | }
873 | if c == end {
874 | return buf, nil
875 | }
876 | buf = append(buf, c)
877 | }
878 | return nil, fmt.Errorf("term: readTo(%s) overlong", showChar(end))
879 | }
880 |
881 | // DisplayString inserts a string into the terminal output, as if it had
882 | // been produced by an underlying tty.
883 | func (t *TermReader) DisplayString(input string) {
884 | r := bufio.NewReader(strings.NewReader(input))
885 | var err error
886 | for err == nil {
887 | err = t.Read(r)
888 | }
889 | }
890 |
891 | // ToString renders the terminal state to a simple string, for use in tests.
892 | func (t *Terminal) ToString() string {
893 | var buf [6]byte
894 | str := ""
895 | for _, l := range t.Lines {
896 | if str != "" {
897 | str += "\n"
898 | }
899 | for _, c := range l {
900 | n := utf8.EncodeRune(buf[:], c.Ch)
901 | str += string(buf[:n])
902 | }
903 | }
904 | return str
905 | }
906 |
907 | func (t *Terminal) Validate() error {
908 | for row, l := range t.Lines {
909 | for col, c := range l {
910 | if err := c.Attr.Validate(); err != nil {
911 | return fmt.Errorf("%d:%d: %s", row, col, err)
912 | }
913 | }
914 | }
915 | return nil
916 | }
917 |
--------------------------------------------------------------------------------