├── 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 | --------------------------------------------------------------------------------