d0690066a89ea543d3fa4e81af0754f4cb3e53dd

Author
Toby Padilla <toby@charm.sh>
Committer
Toby Padilla <toby@charm.sh>
Date

Message

Init

Diff

This diff is truncated to protect this page.

  1diff --git a/.gitignore b/.gitignore
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..bf4ddda60cd929c4ebdbc793e029a3fb0cbac866
  4--- /dev/null
  5+++ b/.gitignore
  6@@ -0,0 +1,2 @@
  7+smoothie
  8+.ssh
  9diff --git a/go.mod b/go.mod
 10new file mode 100644
 11index 0000000000000000000000000000000000000000..932e7c39ea4886cbb53140cf6d179301b4d660bc
 12--- /dev/null
 13+++ b/go.mod
 14@@ -0,0 +1,14 @@
 15+module smoothie
 16+
 17+go 1.16
 18+
 19+replace github.com/charmbracelet/charm => ../charm
 20+
 21+require (
 22+	github.com/charmbracelet/bubbletea v0.14.0
 23+	github.com/charmbracelet/charm v0.8.6
 24+	github.com/charmbracelet/lipgloss v0.2.1
 25+	github.com/gliderlabs/ssh v0.3.3
 26+	github.com/meowgorithm/babyenv v1.3.0
 27+	golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
 28+)
 29diff --git a/go.sum b/go.sum
 30new file mode 100644
 31index 0000000000000000000000000000000000000000..66e43f9de5f914472089c892ddf2af703d20fe38
 32--- /dev/null
 33+++ b/go.sum
 34@@ -0,0 +1,346 @@
 35+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
 36+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 37+github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
 38+github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 39+github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 40+github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 41+github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
 42+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 43+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 44+github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
 45+github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 46+github.com/auth0/go-jwt-middleware v1.0.0/go.mod h1:nX2S0GmCyl087kdNSSItfOvMYokq5PSTG1yGIP5Le4U=
 47+github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 48+github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
 49+github.com/calmh/randomart v1.1.0/go.mod h1:DQUbPVyP+7PAs21w/AnfMKG5NioxS3TbZ2F9MSK/jFM=
 50+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 51+github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 52+github.com/charmbracelet/bubbles v0.8.0/go.mod h1:5WX1sSSjNCgCrzvRMN/z23HxvWaa+AI16Ch0KPZPeDs=
 53+github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg=
 54+github.com/charmbracelet/bubbletea v0.14.0 h1:SBbtRPkZ/wGYyTAn2zrBERes/nwzxZR5QdJ5nvczmFw=
 55+github.com/charmbracelet/bubbletea v0.14.0/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE=
 56+github.com/charmbracelet/lipgloss v0.1.2/go.mod h1:5D8zradw52m7QmxRF6QgwbwJi9je84g8MkWiGN07uKg=
 57+github.com/charmbracelet/lipgloss v0.2.1 h1:knjqLRtlcDsxss6i0CUhNLuXrNW3TQhFDXlXII2UaZA=
 58+github.com/charmbracelet/lipgloss v0.2.1/go.mod h1:uiZUfrHLQN14I0lJ5591WtcHyY1X76pOIPSaRKPY6dE=
 59+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 60+github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
 61+github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc=
 62+github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw=
 63+github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 64+github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 65+github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 66+github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 67+github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 68+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 69+github.com/cosiner/argv v0.1.0/go.mod h1:EusR6TucWKX+zFgtdUsKT2Cvg45K5rtpCcWz4hK06d8=
 70+github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
 71+github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 72+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 73+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 74+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 75+github.com/dgraph-io/badger/v3 v3.2011.1/go.mod h1:0rLLrQpKVQAL0or/lBLMQznhr6dWWX7h5AKnmnqx268=
 76+github.com/dgraph-io/ristretto v0.0.4-0.20210122082011-bb5d392ed82d/go.mod h1:tv2ec8nA7vRpSYX7/MbP52ihrUMXIHit54CQMq8npXQ=
 77+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 78+github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
 79+github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 80+github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 81+github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 82+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 83+github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 84+github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 85+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 86+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 87+github.com/gliderlabs/ssh v0.3.0/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
 88+github.com/gliderlabs/ssh v0.3.3 h1:mBQ8NiOgDkINJrZtoizkC3nDNYgSaWtxyem6S2XHBtA=
 89+github.com/gliderlabs/ssh v0.3.3/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914=
 90+github.com/go-delve/delve v1.5.0/go.mod h1:c6b3a1Gry6x8a4LGCe/CWzrocrfaHvkUxCj3k4bvSUQ=
 91+github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
 92+github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
 93+github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
 94+github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
 95+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
 96+github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 97+github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 98+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 99+github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
100+github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
101+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
102+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
103+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
104+github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
105+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
106+github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
107+github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
108+github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
109+github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
110+github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
111+github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
112+github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
113+github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
114+github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
115+github.com/google/flatbuffers v1.12.0/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
116+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
117+github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
118+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
119+github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
120+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
121+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
122+github.com/google/go-dap v0.2.0/go.mod h1:5q8aYQFnHOAZEMP+6vmq25HKYAEwE+LF5yh7JKrrhSQ=
123+github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
124+github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
125+github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
126+github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
127+github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
128+github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
129+github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
130+github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
131+github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
132+github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
133+github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
134diff --git a/main.go b/main.go
135new file mode 100644
136index 0000000000000000000000000000000000000000..46830542f28470051d12e571a464322d88dffef2
137--- /dev/null
138+++ b/main.go
139@@ -0,0 +1,28 @@
140+package main
141+
142+import (
143+	"smoothie/tui"
144+
145+	"github.com/meowgorithm/babyenv"
146+)
147+
148+type Config struct {
149+	KeyPath string `env:"SMOOTHIE_KEY_PATH" default:".ssh/smoothie_server_ed25519"`
150+	Port    int    `env:"SMOOTHIE_PORT" default:"23231"`
151+}
152+
153+func main() {
154+	var cfg Config
155+	err := babyenv.Parse(&cfg)
156+	if err != nil {
157+		panic(err)
158+	}
159+	s, err := NewServer(cfg.Port, cfg.KeyPath, tui.SessionHandler)
160+	if err != nil {
161+		panic(err)
162+	}
163+	err = s.Start()
164+	if err != nil {
165+		panic(err)
166+	}
167+}
168diff --git a/server.go b/server.go
169new file mode 100644
170index 0000000000000000000000000000000000000000..7ebaeb48a1589c0b25697fc2ba73483236e5ef65
171--- /dev/null
172+++ b/server.go
173@@ -0,0 +1,79 @@
174+package main
175+
176+import (
177+	"fmt"
178+	"log"
179+	"path/filepath"
180+	"strings"
181+
182+	tea "github.com/charmbracelet/bubbletea"
183+	"github.com/charmbracelet/charm/keygen"
184+	"github.com/gliderlabs/ssh"
185+	gossh "golang.org/x/crypto/ssh"
186+)
187+
188+type SessionHandler func(ssh.Session) (tea.Model, error)
189+
190+type Server struct {
191+	server  *ssh.Server
192+	key     gossh.PublicKey
193+	handler SessionHandler
194+}
195+
196+func NewServer(port int, keyPath string, handler SessionHandler) (*Server, error) {
197+	s := &Server{
198+		server:  &ssh.Server{},
199+		handler: handler,
200+	}
201+	s.server.Version = "OpenSSH_7.6p1"
202+	s.server.Addr = fmt.Sprintf(":%d", port)
203+	s.server.Handler = s.sessionHandler
204+	s.server.PasswordHandler = s.passHandler
205+	s.server.PublicKeyHandler = s.authHandler
206+	kps := strings.Split(keyPath, string(filepath.Separator))
207+	kp := strings.Join(kps[:len(kps)-1], string(filepath.Separator))
208+	n := strings.TrimRight(kps[len(kps)-1], "_ed25519")
209+	_, err := keygen.NewSSHKeyPair(kp, n, nil, "ed25519")
210+	if err != nil {
211+		return nil, err
212+	}
213+	k := ssh.HostKeyFile(keyPath)
214+	err = s.server.SetOption(k)
215+	if err != nil {
216+		return nil, err
217+	}
218+	return s, nil
219+}
220+
221+func (srv *Server) sessionHandler(s ssh.Session) {
222+	hpk := s.PublicKey() != nil
223+	log.Printf("%s connect %v %v\n", s.RemoteAddr().String(), hpk, s.Command())
224+	m, err := srv.handler(s)
225+	if err != nil {
226+		log.Printf("%s error %v %s\n", s.RemoteAddr().String(), hpk, err)
227+		s.Exit(1)
228+		return
229+	}
230+	if m != nil {
231+		p := tea.NewProgram(m, tea.WithInput(s), tea.WithOutput(s))
232+		err = p.Start()
233+		if err != nil {
234+			log.Printf("%s error %v %s\n", s.RemoteAddr().String(), hpk, err)
235+			s.Exit(1)
236+			return
237+		}
238+	}
239+	log.Printf("%s disconnect %v %v\n", s.RemoteAddr().String(), hpk, s.Command())
240+}
241+
242+func (srv *Server) authHandler(ctx ssh.Context, key ssh.PublicKey) bool {
243+	return true
244+}
245+
246+func (srv *Server) passHandler(ctx ssh.Context, pass string) bool {
247+	return true
248+}
249+
250+func (srv *Server) Start() error {
251+	return srv.server.ListenAndServe()
252+}
253diff --git a/tui/commands.go b/tui/commands.go
254new file mode 100644
255index 0000000000000000000000000000000000000000..2ca8f35995743e69a7bef225d931ac6bc990cfe2
256--- /dev/null
257+++ b/tui/commands.go
258@@ -0,0 +1,14 @@
259+package tui
260+
261+import (
262+	tea "github.com/charmbracelet/bubbletea"
263+)
264+
265+type windowMsg struct{}
266+
267+func (m *Model) windowChangesCmd() tea.Msg {
268+	w := <-m.windowChanges
269+	m.width = w.Width
270+	m.height = w.Height
271+	return windowMsg{}
272+}
273diff --git a/tui/model.go b/tui/model.go
274new file mode 100644
275index 0000000000000000000000000000000000000000..4a0e1d2366b58bf3745a7ebbbf6ba5d4b77630b2
276--- /dev/null
277+++ b/tui/model.go
278@@ -0,0 +1,98 @@
279+package tui
280+
281+import (
282+	"fmt"
283+
284+	tea "github.com/charmbracelet/bubbletea"
285+	"github.com/gliderlabs/ssh"
286+)
287+
288+type sessionState int
289+
290+const (
291+	startState sessionState = iota
292+	errorState
293+	quittingState
294+	quitState
295+)
296+
297+type stateMsg struct{ state sessionState }
298+type infoMsg struct{ text string }
299+type errMsg struct{ err error }
300+
301+func (e errMsg) Error() string {
302+	return e.err.Error()
303+}
304+
305+func SessionHandler(s ssh.Session) (tea.Model, error) {
306+	pty, changes, active := s.Pty()
307+	if !active {
308+		return nil, fmt.Errorf("you need to do this from a terminal with PTY support")
309+	}
310+	return NewModel(pty.Window.Width, pty.Window.Height, changes), nil
311+}
312+
313+type Model struct {
314+	state         sessionState
315+	error         string
316+	info          string
317+	width         int
318+	height        int
319+	windowChanges <-chan ssh.Window
320+}
321+
322+func NewModel(width int, height int, windowChanges <-chan ssh.Window) *Model {
323+	m := &Model{
324+		width:         width,
325+		height:        height,
326+		windowChanges: windowChanges,
327+	}
328+	m.state = startState
329+	return m
330+}
331+
332+func (m *Model) Init() tea.Cmd {
333+	return tea.Batch(m.windowChangesCmd, tea.HideCursor)
334+}
335+
336+func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
337+	cmds := make([]tea.Cmd, 0)
338+	// Always allow state, error, info, window resize and quit messages
339+	switch msg := msg.(type) {
340+	case stateMsg:
341+		m.state = msg.state
342+	case tea.KeyMsg:
343+		switch msg.String() {
344+		case "q", "ctrl+c":
345+			return m, tea.Quit
346+		}
347+	case errMsg:
348+		m.error = msg.Error()
349+		m.state = errorState
350+		return m, nil
351+	case infoMsg:
352+		m.info = msg.text
353+	case windowMsg:
354+		cmds = append(cmds, m.windowChangesCmd)
355+	case tea.WindowSizeMsg:
356+		m.width = msg.Width
357+		m.height = msg.Height
358+	}
359+	return m, tea.Batch(cmds...)
360+}
361+
362+func (m *Model) View() string {
363+	pad := 6
364+	h := headerStyle.Width(m.width - pad).Render("Smoothie")
365+	f := footerStyle.Render(m.info)
366+	s := ""
367+	content := ""
368+	switch m.state {
369+	case errorState:
370+		s += errorStyle.Render(fmt.Sprintf("Bummer: %s", m.error))
371+	default:
372+		s = normalStyle.Render(fmt.Sprintf("Doing something weird %d", m.state))
373+	}
374+	content = h + "\n" + s + "\n" + f
375+	return appBoxStyle.Render(content)
376+}
377diff --git a/tui/style.go b/tui/style.go
378new file mode 100644
379index 0000000000000000000000000000000000000000..6c219b1117230fc47c63efd21d90d117151ca91a
380--- /dev/null
381+++ b/tui/style.go
382@@ -0,0 +1,30 @@
383+package tui
384+
385+import (
386+	"github.com/charmbracelet/lipgloss"
387+)
388+
389+var appBoxStyle = lipgloss.NewStyle().
390+	PaddingLeft(2).
391+	PaddingRight(2).
392+	MarginBottom(1)
393+
394+var headerStyle = lipgloss.NewStyle().
395+	Foreground(lipgloss.Color("#670083")).
396+	Align(lipgloss.Right).
397+	Bold(true)
398+
399+var normalStyle = lipgloss.NewStyle().
400+	Foreground(lipgloss.Color("#FFFFFF"))
401+
402+var footerStyle = lipgloss.NewStyle().
403+	BorderStyle(lipgloss.Border{Left: ">"}).
404+	BorderForeground(lipgloss.Color("#6D6D6D")).
405+	BorderLeft(true).
406+	Foreground(lipgloss.Color("#373737")).
407+	PaddingLeft(1).
408+	MarginLeft(1).
409+	Bold(true)
410+
411+var errorStyle = lipgloss.NewStyle().
412+	Foreground(lipgloss.Color("#FF00000"))