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