Update Butterfish with `fish` shell compatibilitydarinkishore/butterfish#3
![Logo of Sweep](/_next/image?url=%2Flogo.png&w=64&q=75)
Update Butterfish with `fish` shell compatibility
darinkishore/butterfish#3
> > >
✓ Completed in 6 minutes, 6 months ago using GPT-4 • Book a call • Report a bug
Progress
ModifyChanged
butterfish/shell.go:465-492
Changed butterfish/shell.go
in 8d92bca
474 | // the %%{ and %%} are zsh-specific and tell zsh to not count the enclosed | 474 | // the %%{ and %%} are zsh-specific and tell zsh to not count the enclosed |
475 | // characters when calculating the cursor position | 475 | // characters when calculating the cursor position |
476 | ps1 = "PS1=$'%%{%s%%}'$PS1$'%s%%{ %%?%s%%} '\n" | 476 | ps1 = "PS1=$'%%{%s%%}'$PS1$'%s%%{ %%?%s%%} '\n" |
477 | case "fish": | ||
478 | // the \e[ and \e] are fish-specific and tell fish to not count the enclosed | ||
479 | // characters when calculating the cursor position | ||
480 | ps1 = "set -U fish_prompt \\e[%s\\e]$fish_prompt$'%s\\e[ $?%s\\e] '\n" | ||
477 | default: | 481 | default: |
478 | log.Printf("Unknown shell %s, Butterfish is going to leave the PS1 alone. This means that you won't get a custom prompt in Butterfish, and Butterfish won't be able to parse the exit code of the previous command, used for certain features. Create an issue at https://github.com/bakks/butterfish.", shell) | 482 | log.Printf("Unknown shell %s, Butterfish is going to leave the PS1 alone. This means that you won't get a custom prompt in Butterfish, and Butterfish won't be able to parse the exit code of the previous command, used for certain features. Create an issue at https://github.com/bakks/butterfish.", shell) |
479 | return | 483 | return |
- In the
SetPS1
function, add a case for thefish
shell in the switch statement that sets the PS1 variable. The PS1 variable for thefish
shell should be set in a way that is compatible with thefish
shell's syntax and allows Butterfish to correctly identify and parse the shell prompt. You can refer to thefish
shell documentation or other resources to determine the correct syntax. - After setting the PS1 variable for the
fish
shell, write it to thechildIn
io.Writer using thefmt.Fprintf
function, similar to how it is done for thebash
andzsh
shells.
Modified file with Assistant API
Run GitHub Actions for
butterfish/shell.go
Ran GitHub Actions for 8d92bca4e7385556d7e7d0e35cbd7426c161ebf8:
ModifyChanged
butterfish/shell.go:465-492
Changed butterfish/shell.go
in d360dca
474 | // the %%{ and %%} are zsh-specific and tell zsh to not count the enclosed | 474 | // the %%{ and %%} are zsh-specific and tell zsh to not count the enclosed |
475 | // characters when calculating the cursor position | 475 | // characters when calculating the cursor position |
476 | ps1 = "PS1=$'%%{%s%%}'$PS1$'%s%%{ %%?%s%%} '\n" | 476 | ps1 = "PS1=$'%%{%s%%}'$PS1$'%s%%{ %%?%s%%} '\n" |
477 | case "fish": | ||
478 | // the \e[ and \e] are fish-specific and tell fish to not count the enclosed | ||
479 | // characters when calculating the cursor position | ||
480 | ps1 = "set -U fish_prompt \\e[%s\\e]$fish_prompt$'%s\\e[ $?%s\\e] '\n" | ||
477 | default: | 481 | default: |
478 | log.Printf("Unknown shell %s, Butterfish is going to leave the PS1 alone. This means that you won't get a custom prompt in Butterfish, and Butterfish won't be able to parse the exit code of the previous command, used for certain features. Create an issue at https://github.com/bakks/butterfish.", shell) | 482 | log.Printf("Shell not supported: %s. Butterfish relies on being able to parse the PS1 variable and uses it heavily internally for features like autosuggestions and catching the return status. If your shell isn't supported consider filing an issue at https://github.com/bakks/butterfish.", shell) |
479 | return | 483 | return |
480 | } | 484 | } |
- In the
SetPS1
function, add a default case in the switch statement that logs a message indicating that the shell is not supported by Butterfish. This will provide a clear error message to the user if they try to use a shell that is not supported.
Modified file with Assistant API
Run GitHub Actions for
butterfish/shell.go
Ran GitHub Actions for d360dca35c4d83c10df36f5b1224a3ad1bcdb2d5:
Modify
butterfish/shell.go:398-402
398 if this.Butterfish.Config.Verbose > 1 {
399 log.Printf("State change: %s -> %s", stateNames[this.State], stateNames[state])
400 }
401
402 this.State = state
- In the
setState
function, add a case for thefish
shell in the switch statement that parses the shell from the configuration. This will allow Butterfish to recognize thefish
shell when it is specified in the configuration.
Modifying file with Assistant API
Run GitHub Actions for
butterfish/shell.go
Plan
This is based on the results of the Planning step. The plan may expand from failed GitHub Actions runs.
Run GitHub Actions for
butterfish/shell.go
Run GitHub Actions for
butterfish/shell.go
Run GitHub Actions for
butterfish/shell.go
Code Snippets Found
This is based on the results of the Searching step.
README.md:6-14
6
7## What is this thing?
8
9Butterfish is for people who work from the command line, it adds AI prompting to your shell (bash, zsh) with OpenAI. Think Github Copilot for shell.
10
11Here's how it works: use your shell as normal, start a command with a capital letter to prompt the AI. The AI sees the shell history, so you can ask contextual questions like "Why did that command fail?".
12
13This is a magical UX pattern -- you get high-context AI help exactly when you want it, NO COPY/PASTING.
14
butterfish/shellbuffer.go:9-40
9
10// This holds a buffer that represents a tty shell buffer. Incoming data
11// manipulates the buffer, for example the left arrow will move the cursor left,
12// a backspace would erase the end of the buffer.
13type ShellBuffer struct {
14 // The buffer itself
15 buffer []rune
16 cursor int
17 termWidth int
18 promptLength int
19 color string
20
21 lastAutosuggestLen int
22 lastJumpForward int
23 oldLength int
24 newLength int
25}
26
27func (this *ShellBuffer) SetColor(color string) {
28 this.color = color
29}
30
31func (this *ShellBuffer) Clear() []byte {
32 for i := 0; i < len(this.buffer); i++ {
33 this.buffer[i] = ' '
34 }
35
36 originalCursor := this.cursor
37 this.cursor = 0
38 update := this.calculateShellUpdate(originalCursor)
39
40 this.buffer = make([]rune, 0)
butterfish/shell.go:70-500
70
71func RunShell(ctx context.Context, config *ButterfishConfig) error {
72 envVars := []string{"BUTTERFISH_SHELL=1"}
73
74 ptmx, ptyCleanup, err := ptyCommand(ctx, envVars, []string{config.ShellBinary})
75 if err != nil {
76 return err
77 }
78 defer ptyCleanup()
79
80 bf, err := NewButterfish(ctx, config)
81 if err != nil {
82 return err
83 }
84 //fmt.Println("Starting butterfish shell")
85
86 bf.ShellMultiplexer(ptmx, ptmx, os.Stdin, os.Stdout)
87 return nil
88}
89
90const (
91 historyTypePrompt = iota
92 historyTypeShellInput
93 historyTypeShellOutput
94 historyTypeLLMOutput
95 historyTypeFunctionOutput
96 historyTypeToolOutput
97)
98
99// Turn history type enum to a string
100func HistoryTypeToString(historyType int) string {
101 switch historyType {
102 case historyTypePrompt:
103 return "Prompt"
104 case historyTypeShellInput:
105 return "Shell Input"
106 case historyTypeShellOutput:
107 return "Shell Output"
108 case historyTypeLLMOutput:
109 return "LLM Output"
110 case historyTypeFunctionOutput:
111 return "Function Output"
112 default:
113 return "Unknown"
114 }
115}
116
117type Tokenization struct {
118 InputLength int // the unprocessed length of the pretokenized plus truncated content
119 NumTokens int // number of tokens in the data
120 Data string // tokenized and truncated content
121}
122
123// HistoryBuffer keeps a content buffer, plus an enum of the type of content
124// (user prompt, shell output, etc), plus a cache of tokenizations of the
125// content. Tokenizations are cached for specific encodings, for example
126// newer models use a different encoding than older models.
127type HistoryBuffer struct {
128 Type int
129 Content *ShellBuffer
130 FunctionName string
131 FunctionParams string
132
133 // This is to cache tokenization plus truncation of the content
134 // It maps from encoding name to the tokenization of the output
135 Tokenizations map[string]Tokenization
136}
137
138func (this *HistoryBuffer) SetTokenization(encoding string, inputLength int, numTokens int, data string) {
139 if this.Tokenizations == nil {
140 this.Tokenizations = make(map[string]Tokenization)
141 }
142 this.Tokenizations[encoding] = Tokenization{
143 InputLength: inputLength,
144 NumTokens: numTokens,
145 Data: data,
146 }
147}
148
149func (this *HistoryBuffer) GetTokenization(encoding string, length int) (string, int, bool) {
150 if this.Tokenizations == nil {
151 this.Tokenizations = make(map[string]Tokenization)
152 }
153
154 tokenization, ok := this.Tokenizations[encoding]
155 if !ok {
156 return "", 0, false
157 }
158 if tokenization.InputLength != length {
159 return "", 0, false
160 }
161 return tokenization.Data, tokenization.NumTokens, true
162}
163
164// ShellHistory keeps a record of past shell history and LLM interaction in
165// a slice of HistoryBuffer objects. You can add a new block, append to
166// the last block, and get the the last n bytes of the history as an array of
167// HistoryBlocks.
168type ShellHistory struct {
169 Blocks []*HistoryBuffer
170 mutex sync.Mutex
171}
172
173func NewShellHistory() *ShellHistory {
174 return &ShellHistory{
175 Blocks: make([]*HistoryBuffer, 0),
176 }
177}
178
179func (this *ShellHistory) add(historyType int, block string) {
180 buffer := NewShellBuffer()
181 buffer.Write(block)
182 this.Blocks = append(this.Blocks, &HistoryBuffer{
183 Type: historyType,
184 Content: buffer,
185 })
186}
187
188func (this *ShellHistory) Append(historyType int, data string) {
189 this.mutex.Lock()
190 defer this.mutex.Unlock()
191
192 // if data is empty, we don't want to add a new block
193 if len(data) == 0 {
194 return
195 }
196
197 numBlocks := len(this.Blocks)
198 // if we have a block already, and it matches the type, append to it
199 if numBlocks > 0 {
200 lastBlock := this.Blocks[numBlocks-1]
201
202 if lastBlock.Type == historyType {
203 lastBlock.Content.Write(data)
204 return
205 }
206 }
207
208 // if the history type doesn't match we fall through and add a new block
209 this.add(historyType, data)
210}
211
212func (this *ShellHistory) AddFunctionCall(name, params string) {
213 this.mutex.Lock()
214 defer this.mutex.Unlock()
215
216 this.Blocks = append(this.Blocks, &HistoryBuffer{
217 Type: historyTypeLLMOutput,
218 FunctionName: name,
219 FunctionParams: params,
220 Content: NewShellBuffer(),
221 })
222}
223
224func (this *ShellHistory) AppendFunctionOutput(name, data string) {
225 this.mutex.Lock()
226 defer this.mutex.Unlock()
227
228 // if data is empty, we don't want to add a new block
229 if len(data) == 0 {
230 return
231 }
232
233 numBlocks := len(this.Blocks)
234 var lastBlock *HistoryBuffer
235 // if we have a block already, and it matches the type, append to it
236 if numBlocks > 0 {
237 lastBlock = this.Blocks[numBlocks-1]
238 if lastBlock.Type == historyTypeFunctionOutput && lastBlock.FunctionName == name {
239 lastBlock.Content.Write(data)
240 return
241 }
242 }
243
244 // if the history type doesn't match we fall through and add a new block
245 this.add(historyTypeFunctionOutput, data)
246 lastBlock = this.Blocks[numBlocks]
247 lastBlock.FunctionName = name
248}
249
250// Go back in history for a certain number of bytes.
251func (this *ShellHistory) GetLastNBytes(numBytes int, truncateLength int) []util.HistoryBlock {
252 this.mutex.Lock()
253 defer this.mutex.Unlock()
254
255 var blocks []util.HistoryBlock
256
257 for i := len(this.Blocks) - 1; i >= 0 && numBytes > 0; i-- {
258 block := this.Blocks[i]
259 content := sanitizeTTYString(block.Content.String())
260 if len(content) > truncateLength {
261 content = content[:truncateLength]
262 }
263 if len(content) > numBytes {
264 break // we don't want a weird partial line so we bail out here
265 }
266 blocks = append(blocks, util.HistoryBlock{
267 Type: block.Type,
268 Content: content,
269 })
270 numBytes -= len(content)
271 }
272
273 // reverse the blocks slice
274 for i := len(blocks)/2 - 1; i >= 0; i-- {
275 opp := len(blocks) - 1 - i
276 blocks[i], blocks[opp] = blocks[opp], blocks[i]
277 }
278
279 return blocks
280}
281
282func (this *ShellHistory) IterateBlocks(cb func(block *HistoryBuffer) bool) {
283 this.mutex.Lock()
284 defer this.mutex.Unlock()
285
286 for i := len(this.Blocks) - 1; i >= 0; i-- {
287 cont := cb(this.Blocks[i])
288 if !cont {
289 break
290 }
291 }
292}
293
294// This is not thread safe
295func (this *ShellHistory) LogRecentHistory() {
296 blocks := this.GetLastNBytes(2000, 512)
297 log.Printf("Recent history: =======================================")
298 builder := strings.Builder{}
299 for _, block := range blocks {
300 builder.WriteString(fmt.Sprintf("%s: %s\n", HistoryTypeToString(block.Type), block.Content))
301 }
302 log.Printf(builder.String())
303 log.Printf("=======================================")
304}
305
306func HistoryBlocksToString(blocks []util.HistoryBlock) string {
307 var sb strings.Builder
308 for i, block := range blocks {
309 if i > 0 {
310 sb.WriteString("\n")
311 }
312 sb.WriteString(block.Content)
313 }
314 return sb.String()
315}
316
317const (
318 stateNormal = iota
319 stateShell
320 statePrompting
321 statePromptResponse
322)
323
324var stateNames = []string{
325 "Normal",
326 "Shell",
327 "Prompting",
328 "PromptResponse",
329}
330
331type AutosuggestResult struct {
332 Command string
333 Suggestion string
334}
335
336type ShellColorScheme struct {
337 Prompt string
338 PromptGoal string
339 PromptGoalUnsafe string
340 Error string
341 Command string
342 Autosuggest string
343 Answer string
344 GoalMode string
345}
346
347type ShellState struct {
348 Butterfish *ButterfishCtx
349 ParentOut io.Writer
350 ChildIn io.Writer
351 Sigwinch chan os.Signal
352
353 // set based on model
354 PromptMaxTokens int
355 AutosuggestMaxTokens int
356
357 // The current state of the shell
358 State int
359 GoalMode bool
360 GoalModeBuffer string
361 GoalModeGoal string
362 GoalModeUnsafe bool
363 ActiveFunction string
364 PromptSuffixCounter int
365 ChildOutReader chan *byteMsg
366 ParentInReader chan *byteMsg
367 CursorPosChan chan *cursorPosition
368 PromptOutputChan chan *util.CompletionResponse
369 PrintErrorChan chan error
370 AutosuggestChan chan *AutosuggestResult
371 History *ShellHistory
372 PromptAnswerWriter io.Writer
373 StyleWriter *util.StyleCodeblocksWriter
374 Prompt *ShellBuffer
375 PromptResponseCancel context.CancelFunc
376 Command *ShellBuffer
377 TerminalWidth int
378 Color *ShellColorScheme
379 LastTabPassthrough time.Time
380 parentInBuffer []byte
381 // these are used to estimate number of tokens
382 AutosuggestEncoder *tiktoken.Tiktoken
383 PromptEncoder *tiktoken.Tiktoken
384
385 // autosuggest config
386 AutosuggestEnabled bool
387 LastAutosuggest string
388 AutosuggestCtx context.Context
389 AutosuggestCancel context.CancelFunc
390 AutosuggestBuffer *ShellBuffer
391}
392
393func (this *ShellState) setState(state int) {
394 if this.State == state {
395 return
396 }
397
398 if this.Butterfish.Config.Verbose > 1 {
399 log.Printf("State change: %s -> %s", stateNames[this.State], stateNames[state])
400 }
401
402 this.State = state
403}
404
405func clearByteChan(r <-chan *byteMsg, timeout time.Duration) {
406 // then wait for timeout
407 target := 2
408 seen := 0
409
410 for {
411 select {
412 case <-time.After(timeout):
413 return
414 case msg := <-r:
415 // if msg.Data includes \n we break
416 if bytes.Contains(msg.Data, []byte("\n")) {
417 seen++
418 if seen >= target {
419 return
420 }
421 }
422 continue
423 }
424 }
425}
426
427func (this *ShellState) GetCursorPosition() (int, int) {
428 // send the cursor position request
429 this.ParentOut.Write([]byte(ESC_CUP))
430 // we wait 5s, if we haven't gotten a response by then we likely have a bug
431 timeout := time.After(5000 * time.Millisecond)
432 var pos *cursorPosition
433
434 // the parent in reader watches for these responses, set timeout and
435 // panic if we don't get a response
436 select {
437 case <-timeout:
438 panic(`Timeout waiting for cursor position response, this means that either:
439- Butterfish has frozen due to a bug.
440- You're using a terminal emulator that doesn't work well with butterfish.
441Please submit an issue to https://github.com/bakks/butterfish.`)
442
443 case pos = <-this.CursorPosChan:
444 }
445
446 // it's possible that we have a stale response, so we loop on the channel
447 // until we get the most recent one
448 for {
449 select {
450 case pos = <-this.CursorPosChan:
451 continue
452 default:
453 return pos.Row, pos.Column
454 }
455 }
456}
457
458// This sets the PS1 shell variable, which is the prompt that the shell
459// displays before each command.
460// We need to be able to parse the child shell's prompt to determine where
461// it starts, ends, exit code, and allow customization to show the user that
462// we're inside butterfish shell. The PS1 is roughly the following:
463// PS1 := promptPrefix $PS1 ShellCommandPrompt $? promptSuffix
464func (this *ButterfishCtx) SetPS1(childIn io.Writer) {
465 shell := this.Config.ParseShell()
466 var ps1 string
467
468 switch shell {
469 case "bash", "sh":
470 // the \[ and \] are bash-specific and tell bash to not count the enclosed
471 // characters when calculating the cursor position
472 ps1 = "PS1=$'\\[%s\\]'$PS1$'%s\\[ $?%s\\] '\n"
473 case "zsh":
474 // the %%{ and %%} are zsh-specific and tell zsh to not count the enclosed
475 // characters when calculating the cursor position
476 ps1 = "PS1=$'%%{%s%%}'$PS1$'%s%%{ %%?%s%%} '\n"
477 default:
478 log.Printf("Unknown shell %s, Butterfish is going to leave the PS1 alone. This means that you won't get a custom prompt in Butterfish, and Butterfish won't be able to parse the exit code of the previous command, used for certain features. Create an issue at https://github.com/bakks/butterfish.", shell)
479 return
480 }
481
482 promptIcon := ""
483 if !this.Config.ShellLeavePromptAlone {
484 promptIcon = EMOJI_DEFAULT
485 }
486
487 fmt.Fprintf(childIn,
488 ps1,
489 PROMPT_PREFIX_ESCAPED,
490 promptIcon,
491 PROMPT_SUFFIX_ESCAPED)
492}
493
494// Given a string of terminal output, identify terminal prompts based on the
495// custom PS1 escape sequences we set.
496// Returns:
497// - The last exit code/status seen in the string (i.e. will be non-zero if
498// previous command failed.
499// - The number of prompts identified in the string.
500// - The string with the special prompt escape sequences removed.
README.md:481-563
481
482### Prompt Library
483
484A goal of Butterfish is to make prompts transparent and easily editable. Butterfish will write a prompt library to `~/.config/butterfish/prompts.yaml` and load this every time it runs. You can edit prompts in that file to tweak them. If you edit a prompt then set `OkToReplace: false`, which prevents overwriting.
485
486```
487> head -n 8 ~/.config/butterfish/prompts.yaml
488- name: shell_system_message
489 prompt: 'You are an assistant that helps the user with a Unix shell. Give advice
490 about commands that can be run and examples but keep your answers succinct. Here
491 is system info about the local machine: ''{sysinfo}'''
492 oktoreplace: true
493- name: shell_autocomplete_command
494 prompt: |-
495 You are a unix shell command autocompleter. I will give you the user's history, predict the full command they will type. You will find good suggestions in the user's history, suggest the full command.
496
497```
498
499If you want to see the exact communication between Butterfish and the OpenAI API then set the verbose flag (`-v`) when you run Butterfish, this will print the full prompt and response either to the terminal or to a log file.
500
501#### Example
502
503The `butterfish summarize` command gives you a semantic summary of a file. For example you can run `butterfish summarize ./go.mod`, and it will open that file and give you an English-language summary of what's in it.
504
505When `summarize` runs, it wraps the file contents in the prompt (also there's some functionality for when it won't fit, but let's ignore that). In other words, it says something like "this is a raw text file, summarize it: '{content}'". But maybe this prompt isn't working well for you, or you want it to assume more things about the file, or you want the output to be different than a completely generic summary.
506
507In that case, you can open `~/.config/butterfish/prompts.yaml`, find the prompt named `summarize`, and edit it. Once you edit you should set `oktoreplace` to `false`.
508
509Let's try it - change the `summarize` prompt to say something like "Summarize in spanish", set `oktoreplace`, and then run `butterfish summarize [file]`.
510
511Remember that if you run Butterfish in verbose mode (with `-v`), you will see the prompt when you run it!
512
513### Embeddings
514
515Example:
516
517```
518butterfish index .
519butterfish indexsearch 'Lorem ipsem dolor sit amet'
520butterfish indexquestion 'Lorem ipsem dolor sit amet?'
521```
522
523Butterfish supports creating embeddings for local files and caching them on disk. This is the strategy many projects have been using to add external context into LLM prompts.
524
525You can build an index by running `butterfish index` in a specific directory. This will recursively find all non-binary files, split files into chunks, use the OpenAI embedding API to embed each chunk, and cache the embeddings in a file called `.butterfish_index` in each directory. You can then run `butterfish indexsearch '[search text]'`, which will embed the search text and then search cached embeddings for the most similar chunk. You can also run `butterfish indexquestion '[question]'`, which injects related snippets into a prompt.
526
527You can run `butterfish index` again later to update the index, this will skip over files that haven't been recently changed. Running `butterfish clearindex` will recursively remove `.butterfish_index` files.
528
529The `.butterfish_index` cache files are binary files written using the protobuf schema in `proto/butterfish.proto`. If you check out this repo you can then inspect specific index files with a command like:
530
531```
532protoc --decode DirectoryIndex butterfish/proto/butterfish.proto < .butterfish_index
533```
534
535#### Example
536
537Let's say you have a software project repository that you want to embed, we'll call this project `helloworld`. First we can index it:
538
539```
540butterfish index /path/to/helloworld
541```
542
543That will run recursively, and you should see output as it calculates embeddings. Once those embeddings exist, go to the directory and check if you can use them:
544
545```
546cd /path/to/helloworld
547butterfish indexsearch "printf 'hello world'"
548```
549
550This will search for embeddings that match the string you hand it. Hopefully these are relevant results!
551
552Often you want to not only do that index search, but hand the results into a GPT prompt so that you can ask a question. In that case `butterfish indexquestion` uses the prompt both to search the embeddings, as a prompt to GPT to ask a question.
553
554## Dev Setup
555
556I've been developing Butterfish on an Intel Mac, but it should work fine on ARM Macs and probably work on Linux (untested). Here is how to get set up for development on MacOS:
557
558```
559brew install git go protobuf protoc-gen-go protoc-gen-go-grpc
560git clone https://github.com/bakks/butterfish
561cd butterfish
562make
563./bin/butterfish prompt "Is this thing working?"