├── .gitignore ├── LICENSE.md ├── README.md ├── img ├── rnn-example-1.gif ├── rnn-example-weird.gif └── rnn-success.png ├── keymaps └── rnn-writer.cson ├── lib ├── rnn-writer-config.coffee └── rnn-writer.coffee ├── menus └── rnn-writer.cson ├── package.json ├── styles └── rnn-writer.atom-text-editor.less └── test.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2016 Robin Sloan 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rnn-writer 2 | 3 | This is a package for the [Atom](https://atom.io/) text editor that works with [`torch-rnn-server`](https://github.com/robinsloan/torch-rnn-server) to provide responsive, inline "autocomplete" powered by a recurrent neural network trained on a corpus of sci-fi stories, or another corpus of your choosing. 4 | 5 | 6 | 7 | I explain what this project is all about [here](https://www.robinsloan.com/note/writing-with-the-machine); it's probably worth reading that before continuing. 8 | 9 | ### Installation 10 | 11 | After downloading and installing Atom, get thee to a command line and do the following: 12 | 13 | ``` 14 | git clone https://github.com/robinsloan/rnn-writer.git 15 | cd rnn-writer 16 | apm install 17 | apm link 18 | ``` 19 | 20 | That last command -- `apm` stands for Atom Package Manager -- tells the application about the package. 21 | 22 | Next, open Atom. Go to the Settings screen (`cmd+,`) and select Packages on the left. `rnn-writer` should be listed near the top. Enable it. Close Atom and reopen it again. Now you should be able to access `rnn-writer`'s settings -- again, `cmd+,` and find it near the top. 23 | 24 | ### Configuration 25 | 26 | The only thing missing is the location of a server to provide the "autocompletions." You should download and set up [`torch-rnn-server`](https://github.com/robinsloan/torch-rnn-server) along with its dependencies, which are many -- but you can skip several if you're using a pretrained model. After doing this, tell `rnn-writer` where to find `torch-rnn-server`, e.g. at `http://localhost:8080`. 27 | 28 | ### Use this thing! 29 | 30 | **This package only pays attention to files that have the `.txt` extension**, so if you're typing in a fresh Atom window, be sure to save once (`noveldraft.txt`?) before proceeding. 31 | 32 | One more time: **be sure you're looking at a file with the `.txt` extension.** Then: 33 | 34 | **Activate the package using `cmd-shift-R`.** 35 | 36 | 37 | 38 | Here are the controls, which should feel intuitive: 39 | 40 | | This key| does this 41 | |---------|--------- 42 | |`tab` | show suggested completions at the text insertion point 43 | |`down` | scroll down through completions (and get more if necessary) 44 | |`up` | scroll back up 45 | |`return` or `tab` again | accept the current completion 46 | |`left` or `escape` | reject all completions and reset 47 | |(anything else) | continue editing as usual 48 | 49 | You can always use `cmd-shift-R` to deactivate the package and type normally again. 50 | 51 | If you run into problems or have ideas for improvements, don't hesitate to open an issue. This is the first time I've bundled up code for other people to use, so I'm sure I made -- _am currently making_ -- some mistakes. 52 | 53 | Enjoy! 54 | 55 | 56 | -------------------------------------------------------------------------------- /img/rnn-example-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsloan/rnn-writer/caf2a316f81f69e02e508f64ce0be31d160d8a76/img/rnn-example-1.gif -------------------------------------------------------------------------------- /img/rnn-example-weird.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsloan/rnn-writer/caf2a316f81f69e02e508f64ce0be31d160d8a76/img/rnn-example-weird.gif -------------------------------------------------------------------------------- /img/rnn-success.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/robinsloan/rnn-writer/caf2a316f81f69e02e508f64ce0be31d160d8a76/img/rnn-success.png -------------------------------------------------------------------------------- /keymaps/rnn-writer.cson: -------------------------------------------------------------------------------- 1 | "atom-text-editor[data-grammar=\"text plain\"]:not([mini])": 2 | "shift-cmd-R": "rnn-writer:toggle" 3 | "tab": "rnn-writer:suggest" 4 | "up": "rnn-writer:scroll-up-suggestion" 5 | "down": "rnn-writer:scroll-down-suggestion" 6 | "right": "rnn-writer:accept-suggestion-right" 7 | "enter": "rnn-writer:accept-suggestion-enter" 8 | "left": "rnn-writer:cancel-suggestion-left" 9 | "escape": "rnn-writer:cancel-suggestion-esc" 10 | -------------------------------------------------------------------------------- /lib/rnn-writer-config.coffee: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | numberOfSuggestionsPerRequest: 3 | order: 1 4 | type: "integer" 5 | title: "Number of suggestions per request" 6 | maximum: 10 7 | default: 5 8 | lookbackLength: 9 | order: 2 10 | type: "integer" 11 | title: "Lookback length" 12 | description: "How many characters should we send as sample text?" 13 | maximum: 256 14 | default: 48 15 | overrideBracketMatcher: 16 | order: 3 17 | type: "boolean" 18 | title: "Override automatic matching of quotation marks?" 19 | description: "Because it's annoying when you're writing prose..." 20 | default: true 21 | localSuggestionGenerator: 22 | order: 4 23 | type: "string" 24 | title: "Location of torch-rnn-server" 25 | description: "Including protocol and port" 26 | default: "http://localhost:8080" 27 | textBargains: 28 | order: 5 29 | type: "object" 30 | title: "Are you using text.bargains??" 31 | properties: 32 | usingTextBargains: 33 | order: 1 34 | type: "boolean" 35 | title: "Yes, I'm using text.bargains" 36 | default: false 37 | apiKey: 38 | order: 2 39 | type: "string" 40 | title: "API key" 41 | description: "If you need one, email `api@robinsloan.com`" 42 | default: "op3n-s3s4m3" 43 | } 44 | -------------------------------------------------------------------------------- /lib/rnn-writer.coffee: -------------------------------------------------------------------------------- 1 | {Range, Point, CompositeDisposable, NotificationManager} = require "atom" 2 | 3 | request = require "request-json" 4 | sfx = require "sfx" 5 | # nlp = require "nlp_compromise" # skipping this for now 6 | 7 | module.exports = RNNWriter = 8 | 9 | # let's muddle through CoffeeScript together, shall we 10 | 11 | config: require "./rnn-writer-config" 12 | 13 | keySubscriptions: null # do I need to list these here?? I don't know 14 | cursorSubscription: null 15 | 16 | activate: (state) -> 17 | @keySubscriptions = new CompositeDisposable 18 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:toggle": => @toggle() 19 | 20 | # note: all these key command wrapper functions are down at the very bottom 21 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:suggest": => @keySuggest() 22 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:scroll-up-suggestion": => @keyScrollUpSuggestion() 23 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:scroll-down-suggestion": => @keyScrollDownSuggestion() 24 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:accept-suggestion-right": => @keyAcceptSuggestion("right") 25 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:accept-suggestion-enter": => @keyAcceptSuggestion("enter") 26 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:cancel-suggestion-left": => @keyCancelSuggestion("left") 27 | @keySubscriptions.add atom.commands.add "atom-workspace", "rnn-writer:cancel-suggestion-esc": => @keyCancelSuggestion("escape") 28 | 29 | @running = false 30 | 31 | toggle: -> 32 | if not @running 33 | if atom.config.get("rnn-writer.overrideBracketMatcher") 34 | atom.config.set("bracket-matcher.autocompleteBrackets", false) # :) 35 | 36 | @LOOKBACK_LENGTH = atom.config.get("rnn-writer.lookbackLength") 37 | @NUM_SUGGESTIONS_PER_REQUEST = atom.config.get("rnn-writer.numberOfSuggestionsPerRequest") 38 | if atom.config.get("rnn-writer.textBargains.usingTextBargains") 39 | @GENERATOR_BASE = "https://text.bargains" 40 | @API_KEY = atom.config.get("rnn-writer.textBargains.apiKey") 41 | else if atom.config.get("rnn-writer.localSuggestionGenerator") 42 | @GENERATOR_BASE = atom.config.get("rnn-writer.localSuggestionGenerator") 43 | else 44 | @showError "There's no server specified in the `rnn-writer` package settings." 45 | return 46 | 47 | @GET_MORE_SUGGESTIONS_THRESHOLD = 3 48 | 49 | @client = request.createClient(@GENERATOR_BASE) 50 | if @API_KEY then @client.headers["x-api-key"] = @API_KEY 51 | 52 | @client.get "/", (error, response, body) => 53 | if error 54 | console.log "...error." 55 | console.log JSON.stringify(error, null, 2) 56 | @showError "Tried to start RNN Writer, but couldn't reach the server. Check your developer console for more details." 57 | @running = false 58 | else 59 | successMessage = "RNN Writer is up and running. Press `tab` for completions." 60 | if @API_KEY and body["message"] then successMessage += " " + body["message"] 61 | @showMessage successMessage 62 | @running = true 63 | 64 | @reset "Setting all vars for the first time." 65 | 66 | else 67 | @showMessage "RNN Writer has shut down." 68 | @running = false 69 | @reset "Shutting down for now." 70 | 71 | deactivate: -> 72 | @reset "Deactivated!" 73 | if @keySubscriptions? 74 | @keySubscriptions.dispose() 75 | if @cursorSubscription? 76 | @cursorSubscription.dispose() 77 | 78 | reset: (message) -> 79 | @suggestions = [] 80 | @suggestionIndex = 0 81 | [@currentStartText, @currentSuggestionText] = ["", ""] 82 | [@offeringSuggestions, @changeSuggestionInProgress] = [false, false] 83 | if @suggestionMarker? 84 | @suggestionMarker.destroy() 85 | if @spinner? 86 | @spinner.destroy() 87 | 88 | console.log message 89 | 90 | # IGNORE THIS PART 91 | # not currently used 92 | 93 | updateEntities: -> 94 | @editor = atom.workspace.getActiveTextEditor() 95 | if @editor.getBuffer().getText().split(" ").length > 4 96 | nlpText = nlp.text @editor.getBuffer().getText() 97 | @people = if nlpText.people().length > 0 98 | nlpText.people().map (entity) -> entity.text 99 | else 100 | ["she", "Jenny", "Jenny Nebula"] 101 | else 102 | @people = ["she", "Jenny", "Jenny Nebula"] 103 | 104 | randomFrom: (array) -> 105 | return array[Math.floor(Math.random()*array.length)] 106 | 107 | interpolateEntityIntoSuggestion: (suggestion) -> 108 | # person placeholder char is @ 109 | if suggestion.includes("@") 110 | suggestion = suggestion.replace(/@/g, @randomFrom(@people)) 111 | 112 | return suggestion 113 | 114 | # OK STOP IGNORING 115 | 116 | # interface 117 | 118 | showMessage: (messageText) -> 119 | atom.notifications.addInfo("🤖 " + messageText, dismissable: true, icon: "radio-tower") 120 | 121 | showError: (errorText) -> 122 | sfx.basso() 123 | atom.notifications.addError("🤖 " + errorText, dismissable: true, icon: "stop") 124 | 125 | showSpinner: -> # while waiting for server response 126 | if @spinner? 127 | @spinner.destroy() 128 | 129 | spinnerSpan = document.createElement "span" 130 | spinnerSpan.className = "loading loading-spinner-tiny inline-block rnn-spinner-hack" 131 | 132 | buffer = @editor.getBuffer() 133 | startCharIndex = buffer.characterIndexForPosition(@editor.getCursorBufferPosition()) 134 | currentSuggestionEndPos = buffer.positionForCharacterIndex(startCharIndex + @currentSuggestionText.length) 135 | 136 | @spinner = buffer.markPosition(currentSuggestionEndPos) 137 | @editor.decorateMarker(@spinner, {type: "overlay", position: "head", item: spinnerSpan}) 138 | 139 | # vaguely chronological application lifecycle begins here 140 | 141 | lookBackToGetStartText: (howManyChars) -> 142 | # this is very step-by-step to make it easier for me to follow 143 | buffer = @editor.getBuffer() 144 | endPos = @editor.getCursorBufferPosition() 145 | endCharIndex = buffer.characterIndexForPosition(endPos) 146 | startCharIndex = endCharIndex - howManyChars 147 | startPos = buffer.positionForCharacterIndex(startCharIndex) 148 | startTextRange = new Range(startPos, endPos) 149 | return @editor.getBuffer().getTextInRange(startTextRange) 150 | 151 | suggest: -> 152 | @offeringSuggestions = true 153 | 154 | # make double extra sure we have the current editor 155 | @editor = atom.workspace.getActiveTextEditor() 156 | @editor.setSoftWrapped(true) # it is perhaps a bit aggro to put this here, but it kept bothering me 157 | 158 | # watch the cursor in this editor 159 | if @cursorSubscription? 160 | @cursorSubscription.dispose() 161 | @cursorSubscription = new CompositeDisposable 162 | @cursorSubscription.add @editor.onDidChangeCursorPosition => @loseFocus() 163 | 164 | # showtime! 165 | @currentStartText = @lookBackToGetStartText(@LOOKBACK_LENGTH) 166 | @getSuggestions() 167 | 168 | queryForCurrentStartText: -> 169 | return "/generate?start_text=" + encodeURIComponent(@currentStartText) + "&n=" + @NUM_SUGGESTIONS_PER_REQUEST 170 | 171 | getSuggestions: -> 172 | if @suggestions.length == 0 173 | @suggestionPos = new Point(@editor.getCursorBufferPosition().row, @editor.getCursorBufferPosition().column) # ugh?? 174 | 175 | @showSpinner() 176 | 177 | console.log("Fetching suggestions from server...") 178 | @client.get @queryForCurrentStartText(), (error, response, body) => 179 | if @spinner? 180 | @spinner.destroy() 181 | if error 182 | console.log "...error." 183 | @showError "
" + JSON.stringify(error, null, 2) + "
" 184 | @reset "Network error (see notification)" 185 | else 186 | if body["message"] 187 | console.log "...error." 188 | switch body["message"] 189 | when "Network error communicating with endpoint" then @showError "Looks like the server is offline." 190 | when "Forbidden" then @showError "That API key doesn't appear to be valid." 191 | else @showError "The server replied with this error:
" + body["message"] + "
" 192 | @reset "Network error (see notification)" 193 | else 194 | startTextForThisRequest = decodeURIComponent(body["start_text"]).replace(/\+/g, " "); # that extra replace is annoying 195 | if @offeringSuggestions and startTextForThisRequest == @currentStartText # be careful! things might have changed! 196 | console.log "...success." 197 | if @suggestions.length > 0 198 | @suggestions = @suggestions.concat(body["completions"]) 199 | else 200 | @suggestions = body["completions"] 201 | @suggestionIndex = 0 202 | @changeSuggestion() 203 | else 204 | # can get into some weird states here, but it's fine for now 205 | console.log "Note: received outdated server reply. Ignoring." 206 | 207 | changeSuggestion: -> 208 | @changeSuggestionInProgress = true # dear event handler: please don't respond to cursor moves while in this block 209 | 210 | newSuggestionText = @suggestions[@suggestionIndex] + " " # always with the extra space; this might be annoying? 211 | 212 | # get start point 213 | buffer = @editor.getBuffer() 214 | startCharIndex = buffer.characterIndexForPosition(@suggestionPos) 215 | 216 | # clear old text 217 | oldEndPos = buffer.positionForCharacterIndex(startCharIndex + @currentSuggestionText.length) 218 | @editor.setTextInBufferRange(new Range(@suggestionPos, oldEndPos), "") 219 | 220 | @editor.setCursorBufferPosition(@suggestionPos) # go back to the place where this all started 221 | @editor.insertText(newSuggestionText) # insert new text 222 | @editor.setCursorBufferPosition(@suggestionPos) # keep the cursor where it was 223 | 224 | # mark the new text's region 225 | newEndPos = buffer.positionForCharacterIndex(startCharIndex + newSuggestionText.length) 226 | if @suggestionMarker? 227 | @suggestionMarker.destroy() 228 | @suggestionMarker = @editor.markBufferRange(new Range(@suggestionPos, newEndPos), invalidate: "inside") 229 | @editor.decorateMarker(@suggestionMarker, type: "highlight", class: "rnn-suggestion") 230 | 231 | # the new text becomes the old text 232 | @currentSuggestionText = newSuggestionText + "" 233 | 234 | sfx.pop() # :) 235 | @changeSuggestionInProgress = false # back to normal 236 | 237 | cancelSuggestion: -> 238 | buffer = @editor.getBuffer() 239 | startCharIndex = buffer.characterIndexForPosition(@suggestionPos) 240 | endPos = buffer.positionForCharacterIndex(startCharIndex + @currentSuggestionText.length) 241 | @editor.setTextInBufferRange(new Range(@suggestionPos, endPos), "") 242 | @editor.setCursorBufferPosition(@suggestionPos) 243 | 244 | @reset "Suggestion canceled." 245 | 246 | acceptSuggestion: (moveCursorForward) -> 247 | if moveCursorForward 248 | buffer = @editor.getBuffer() 249 | startCharIndex = buffer.characterIndexForPosition(@suggestionPos) 250 | endCharIndex = startCharIndex + @currentSuggestionText.length 251 | endPos = buffer.positionForCharacterIndex(endCharIndex) 252 | @editor.setCursorBufferPosition(endPos) 253 | 254 | @reset "Suggestion accepted" 255 | 256 | loseFocus: -> 257 | if @offeringSuggestions 258 | unless @changeSuggestionInProgress 259 | @reset "Suggestion accepted implicitly" 260 | 261 | # key command wrapper functions 262 | 263 | keySuggest: -> 264 | @editor = atom.workspace.getActiveTextEditor() 265 | 266 | if @running 267 | sfx.tink() 268 | if @offeringSuggestions 269 | @acceptSuggestion(true) 270 | else 271 | @suggest() 272 | else 273 | atom.commands.dispatch(atom.views.getView(@editor), "editor:indent") 274 | 275 | keyScrollUpSuggestion: -> 276 | @editor = atom.workspace.getActiveTextEditor() 277 | 278 | if @running and @offeringSuggestions 279 | if @suggestionIndex > 0 280 | @suggestionIndex -= 1 281 | @changeSuggestion() 282 | else 283 | sfx.basso() 284 | else 285 | atom.commands.dispatch(atom.views.getView(@editor), "core:move-up") 286 | 287 | keyScrollDownSuggestion: -> 288 | @editor = atom.workspace.getActiveTextEditor() 289 | 290 | if @running and @offeringSuggestions 291 | if @suggestionIndex+1 < @suggestions.length 292 | @suggestionIndex += 1 293 | @changeSuggestion() 294 | else 295 | sfx.basso() 296 | 297 | if (@suggestions.length - @suggestionIndex) < @GET_MORE_SUGGESTIONS_THRESHOLD 298 | @getSuggestions() 299 | else 300 | atom.commands.dispatch(atom.views.getView(@editor), "core:move-down") 301 | 302 | keyAcceptSuggestion: (key) -> 303 | @editor = atom.workspace.getActiveTextEditor() 304 | 305 | if @running and @offeringSuggestions 306 | if key == "right" 307 | @acceptSuggestion(false) 308 | if key == "enter" 309 | @acceptSuggestion(true) 310 | else 311 | if key == "right" 312 | atom.commands.dispatch(atom.views.getView(@editor), "core:move-right") 313 | if key == "enter" 314 | atom.commands.dispatch(atom.views.getView(@editor), "editor:newline") 315 | 316 | keyCancelSuggestion: (key) -> 317 | console.log(key) 318 | @editor = atom.workspace.getActiveTextEditor() 319 | 320 | if @running and @offeringSuggestions 321 | @cancelSuggestion() 322 | else 323 | if key == "left" 324 | atom.commands.dispatch(atom.views.getView(@editor), "core:move-left") 325 | if key == "escape" 326 | atom.commands.dispatch(atom.views.getView(@editor), "editor:consolidate-selections") 327 | -------------------------------------------------------------------------------- /menus/rnn-writer.cson: -------------------------------------------------------------------------------- 1 | 'context-menu': 2 | 'atom-text-editor[data-grammar=\"text plain\"]:not([mini])': [ 3 | { 4 | } 5 | ] 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rnn-writer", 3 | "main": "./lib/rnn-writer", 4 | "version": "0.0.1", 5 | "description": "RNN Writer", 6 | "repository": "https://github.com/robinsloan/rnn-writer", 7 | "license": "Apache", 8 | "engines": { 9 | "atom": ">=1.0.0 <2.0.0" 10 | }, 11 | "dependencies": { 12 | "request-json": ">=0.5.6", 13 | "sfx": ">=0.1.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /styles/rnn-writer.atom-text-editor.less: -------------------------------------------------------------------------------- 1 | .rnn-spinner-hack { 2 | top: -1.75rem; 3 | left: 0.75rem; 4 | } 5 | 6 | .highlights { 7 | .rnn-suggestion .region { 8 | border-radius: 3px; 9 | border: 1px solid rgba(32,182,132,0.5); 10 | background-color: rgba(32,182,132,0.25); 11 | } 12 | .rnn-start-text .region { 13 | border-bottom: 1px dotted #eee; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test.txt: -------------------------------------------------------------------------------- 1 | The ship careened around the dark side of Planet Zero, headed toward 2 | --------------------------------------------------------------------------------