commit 55c3548

sewn  ·  2026-07-23 17:28:12 +0000 UTC
parent 7c80055
Revert "Add spell checking via harper-ls"

This reverts commit 8f6b08f151bb8fc4b2b301685b9def045cd04f27.
7 files changed,  +2, -515
M app.go
M app.go
+0, -30
  1@@ -154,8 +154,6 @@ type App struct {
  2 	shownPasteHint     bool
  3 
  4 	closing atomic.Bool
  5-
  6-	harper *harperState
  7 }
  8 
  9 func NewApp(cfg Config) (app *App, err error) {
 10@@ -252,7 +250,6 @@ func (app *App) Close() {
 11 		session.Close()
 12 	}
 13 	ui.DBusStop()
 14-	app.harperClose()
 15 	app.closing.Store(true)
 16 	go func() {
 17 		// drain remaining events
 18@@ -275,7 +272,6 @@ func (app *App) Run() {
 19 	if app.lastCloseTime.IsZero() {
 20 		app.lastCloseTime = time.Now()
 21 	}
 22-	app.harperInit()
 23 	go app.uiLoop()
 24 	go app.ircLoop("")
 25 	app.eventLoop()
 26@@ -688,16 +684,6 @@ func (app *App) handleUIEvent(ev interface{}) bool {
 27 		} else {
 28 			app.uploadingProgress = &ev.Progress
 29 		}
 30-	case *events.EventSpellCheck:
 31-		text := string(app.win.InputContent())
 32-		typos := make([]events.TypoRange, len(ev.Typos))
 33-		for i, t := range ev.Typos {
 34-			typos[i] = events.TypoRange{
 35-				Start: utf16OffsetToRuneIndex(text, t.Start),
 36-				End:   utf16OffsetToRuneIndex(text, t.End),
 37-			}
 38-		}
 39-		app.win.SetTypos(typos)
 40 	case *events.EventOpenLink:
 41 		host, target, netID := app.findNetworkByLink(ev.Link)
 42 		if netID != "" {
 43@@ -855,13 +841,11 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 44 			if i := app.win.VerticalBufferOffset(y); i == app.win.ClickedBuffer() {
 45 				app.win.GoToBufferNo(i)
 46 				app.clearBufferCommand()
 47-				app.spellCheck()
 48 			}
 49 		} else if app.win.ChannelWidth() == 0 && y == h-1 {
 50 			if i := app.win.HorizontalBufferOffset(x); i >= 0 && i == app.win.ClickedBuffer() {
 51 				app.win.GoToBufferNo(i)
 52 				app.clearBufferCommand()
 53-				app.spellCheck()
 54 			}
 55 		} else if x > w-app.win.MemberWidth() && y < h-memberItems*2 {
 56 			if i := y - 2 + app.win.MemberOffset(); i >= 0 && i == app.win.ClickedMember() {
 57@@ -932,7 +916,6 @@ func (app *App) handleAction(action string, args ...string) {
 58 	case "quit":
 59 		if app.win.InputClear() {
 60 			app.typing()
 61-			app.spellCheck()
 62 		} else {
 63 			app.win.InputSet("/quit")
 64 		}
 65@@ -953,19 +936,15 @@ func (app *App) handleAction(action string, args ...string) {
 66 	case "buffer-next":
 67 		app.win.NextBuffer()
 68 		app.win.ScrollToBuffer()
 69-		app.spellCheck()
 70 	case "buffer-previous":
 71 		app.win.PreviousBuffer()
 72 		app.win.ScrollToBuffer()
 73-		app.spellCheck()
 74 	case "buffer-next-unread":
 75 		app.win.NextUnreadBuffer()
 76 		app.win.ScrollToBuffer()
 77-		app.spellCheck()
 78 	case "buffer-previous-unread":
 79 		app.win.PreviousUnreadBuffer()
 80 		app.win.ScrollToBuffer()
 81-		app.spellCheck()
 82 	case "cursor-right-word":
 83 		app.win.InputRightWord()
 84 	case "cursor-left-word":
 85@@ -981,39 +960,32 @@ func (app *App) handleAction(action string, args ...string) {
 86 	case "cursor-delete-previous-word":
 87 		if app.win.InputDeleteWord() {
 88 			app.typing()
 89-			app.spellCheck()
 90 		}
 91 	case "cursor-delete-next-word":
 92 		if app.win.InputDeleteNextWord() {
 93 			app.typing()
 94-			app.spellCheck()
 95 		}
 96 	case "cursor-delete-previous":
 97 		if app.win.InputBackspace() {
 98 			app.typing()
 99-			app.spellCheck()
100 		}
101 	case "cursor-delete-next":
102 		if app.win.InputDelete() {
103 			app.typing()
104-			app.spellCheck()
105 		}
106 	case "cursor-delete-before":
107 		if app.win.InputDeleteBefore() {
108 			app.typing()
109-			app.spellCheck()
110 		}
111 	case "cursor-delete-after":
112 		if app.win.InputDeleteAfter() {
113 			app.typing()
114-			app.spellCheck()
115 		}
116 	case "search-editor":
117 		app.win.InputBackSearch()
118 	case "auto-complete":
119 		if app.win.InputAutoComplete() {
120 			app.typing()
121-			app.spellCheck()
122 		}
123 	case "close-overlay":
124 		app.win.CloseOverlay()
125@@ -1053,7 +1025,6 @@ func (app *App) handleAction(action string, args ...string) {
126 				maxInt := int(^uint(0) >> 1)
127 				app.win.GoToBufferNo(maxInt)
128 			}
129-			app.spellCheck()
130 		}
131 	case "paste-hint":
132 		if !app.shownPasteHint {
133@@ -1165,7 +1136,6 @@ func (app *App) handleKeyEvent(ev vaxis.Key) {
134 			app.win.InputRune(r)
135 		}
136 		app.typing()
137-		app.spellCheck()
138 		return
139 	}
140 
+2, -12
 1@@ -98,9 +98,8 @@ type Config struct {
 2 
 3 	Channels []string
 4 
 5-	Typings    bool
 6-	Mouse      bool
 7-	SpellCheck bool
 8+	Typings bool
 9+	Mouse   bool
10 
11 	Highlights       []string
12 	OnHighlightPath  string
13@@ -145,7 +144,6 @@ func Defaults() Config {
14 		Channels:         nil,
15 		Typings:          true,
16 		Mouse:            true,
17-		SpellCheck:       false,
18 		Highlights:       nil,
19 		OnHighlightPath:  "",
20 		OnHighlightBeep:  false,
21@@ -387,14 +385,6 @@ func unmarshal(filename string, cfg *Config) (err error) {
22 			if cfg.Mouse, err = strconv.ParseBool(mouse); err != nil {
23 				return err
24 			}
25-		case "spell-check":
26-			var spellCheck string
27-			if err := d.ParseParams(&spellCheck); err != nil {
28-				return err
29-			}
30-			if cfg.SpellCheck, err = strconv.ParseBool(spellCheck); err != nil {
31-				return err
32-			}
33 		case "colors":
34 			for _, child := range d.Children {
35 				var colorStr string
+0, -4
 1@@ -122,10 +122,6 @@ pane-widths {
 2 *mouse*
 3 	Enable or disable mouse support.  Defaults to true.
 4 
 5-*spell-check*
 6-	Enable spell checking using harper-ls. Requires harper-ls to be installed.
 7-	English only for now. Defaults to false.
 8-
 9 *colors* { ... }
10 	Settings for colors of different UI elements.
11 
+0, -9
 1@@ -49,12 +49,3 @@ type EventFileUpload struct {
 2 type EventOpenLink struct {
 3 	Link string
 4 }
 5-
 6-type TypoRange struct {
 7-	Start int
 8-	End   int
 9-}
10-
11-type EventSpellCheck struct {
12-	Typos []TypoRange
13-}
+0, -439
  1@@ -1,439 +0,0 @@
  2-package senpai
  3-
  4-import (
  5-	"bufio"
  6-	"encoding/json"
  7-	"fmt"
  8-	"io"
  9-	"os"
 10-	"os/exec"
 11-	"slices"
 12-	"strconv"
 13-	"strings"
 14-	"unicode/utf16"
 15-
 16-	"git.sr.ht/~delthas/senpai/events"
 17-	"git.sr.ht/~delthas/senpai/ui"
 18-)
 19-
 20-// Rules disabled for informal IM context.
 21-// Full list: https://writewithharper.com/docs/rules
 22-var harperDisabledLinters = map[string]bool{
 23-	// Formality
 24-	"SentenceCapitalization":     false,
 25-	"CapitalizePersonalPronouns": false,
 26-	"LongSentences":              false,
 27-	"UnclosedQuotes":             false,
 28-	"OxfordComma":                false,
 29-	"Spaces":                     false,
 30-	"NoFrenchSpaces":             false,
 31-	"AvoidCurses":                false,
 32-	"Hedging":                    false,
 33-	"FillerWords":                false,
 34-	"DiscourseMarkers":           false,
 35-
 36-	// Initialisms (btw, imo, ttyl, brb, etc.)
 37-	"ByTheWay":           false,
 38-	"InMyOpinion":        false,
 39-	"InMyHumbleOpinion":  false,
 40-	"TalkToYouLater":     false,
 41-	"ForWhatItsWorth":    false,
 42-	"AsFarAsIKnow":       false,
 43-	"BeRightBack":        false,
 44-	"OhMyGod":            false,
 45-	"AsSoonAsPossible":   false,
 46-	"InRealLife":         false,
 47-	"ForYourInformation": false,
 48-	"IDontKnow":          false,
 49-	"IfYouKnowYouKnow":   false,
 50-	"InCaseYouMissedIt":  false,
 51-	"ToBeHonest":         false,
 52-	"PleaseTakeALook":    false,
 53-	"NeverMind":          false,
 54-	"IfIRecallCorrectly": false,
 55-	"Really":             false,
 56-	"ExplainLikeImFive":  false,
 57-	"RedundantIIRC":      false,
 58-
 59-	// Casual abbreviations
 60-	"ExpandThrough":                false,
 61-	"ExpandWith":                   false,
 62-	"ExpandWithout":                false,
 63-	"ExpandForward":                false,
 64-	"ExpandBecause":                false,
 65-	"ExpandPrevious":               false,
 66-	"ExpandControl":                false,
 67-	"ExpandMinimum":                false,
 68-	"ExpandTimeShorthands":         false,
 69-	"ExpandMemoryShorthands":       false,
 70-	"ExpandParameter":              false,
 71-	"ExpandDependencies":           false,
 72-	"ExpandStandardInputAndOutput": false,
 73-	"Cybersec":                     false,
 74-
 75-	// Overly prescriptive style
 76-	"Dashes":         false,
 77-	"EllipsisLength": false,
 78-	"SendAnEmailTo":  false,
 79-	"Touristic":      false,
 80-	"BoringWords":    false,
 81-	"Freezing":       false,
 82-	"Starving":       false,
 83-	"Excellent":      false,
 84-	"FatalOutcome":   false,
 85-	"AvoidAndAlso":   false,
 86-	"CondenseAllThe": false,
 87-	"DotInitialisms": false,
 88-
 89-	// Too aggressive for technical/casual chat
 90-	"SplitWords":                false,
 91-	"OrthographicConsistency":   false,
 92-	"ToDoHyphen":                false,
 93-	"PhrasalVerbAsCompoundNoun": false,
 94-	"DisjointPrefixes":          false,
 95-	"NeedToNoun":                false,
 96-	"MissingTo":                 false,
 97-	"MoreAdjective":             false,
 98-	"RightClick":                false,
 99-	"MassNouns":                 false,
100-	"HaveTakeALook":             false,
101-}
102-
103-type harperConfig struct {
104-	HarperLS harperLSConfig `json:"harper-ls"`
105-}
106-
107-type harperLSConfig struct {
108-	Linters      map[string]bool `json:"linters"`
109-	UserDictPath string          `json:"userDictPath,omitempty"`
110-}
111-
112-type lspPosition struct {
113-	Character int `json:"character"`
114-}
115-
116-type lspRange struct {
117-	Start lspPosition `json:"start"`
118-	End   lspPosition `json:"end"`
119-}
120-
121-type lspDiagnostic struct {
122-	Range lspRange `json:"range"`
123-}
124-
125-type lspMessage struct {
126-	ID     json.RawMessage `json:"id"`
127-	Method string          `json:"method"`
128-	Params json.RawMessage `json:"params"`
129-}
130-
131-type lspDiagnosticsParams struct {
132-	Diagnostics []lspDiagnostic `json:"diagnostics"`
133-}
134-
135-type harperState struct {
136-	cmd    *exec.Cmd
137-	stdin  io.WriteCloser
138-	stdout *bufio.Reader
139-	writes chan []byte
140-	textCh chan string
141-
142-	reqID    int
143-	ver      int
144-	dictPath string
145-	dictPrev string
146-}
147-
148-func (app *App) harperInit() {
149-	if !app.cfg.SpellCheck || app.cfg.Transient {
150-		return
151-	}
152-	path, err := exec.LookPath("harper-ls")
153-	if err != nil {
154-		return
155-	}
156-	cmd := exec.Command(path, "--stdio")
157-	stdin, err := cmd.StdinPipe()
158-	if err != nil {
159-		return
160-	}
161-	stdout, err := cmd.StdoutPipe()
162-	if err != nil {
163-		stdin.Close()
164-		return
165-	}
166-	if err := cmd.Start(); err != nil {
167-		stdin.Close()
168-		return
169-	}
170-
171-	dictFile, err := os.CreateTemp("", "senpai-harper-dict-*.txt")
172-	if err != nil {
173-		stdin.Close()
174-		cmd.Process.Kill()
175-		return
176-	}
177-	dictFile.Close()
178-
179-	h := &harperState{
180-		cmd:      cmd,
181-		stdin:    stdin,
182-		stdout:   bufio.NewReader(stdout),
183-		writes:   make(chan []byte, 64),
184-		textCh:   make(chan string, 1),
185-		dictPath: dictFile.Name(),
186-	}
187-	go h.writeLoop()
188-
189-	if err := h.sendRequest("initialize", json.RawMessage(`{
190-		"capabilities": {
191-			"textDocument": {
192-				"publishDiagnostics": {}
193-			}
194-		},
195-		"rootUri": null,
196-		"processId": null
197-	}`)); err != nil {
198-		cmd.Process.Kill()
199-		return
200-	}
201-
202-	if err := h.sendNotification("initialized", json.RawMessage(`{}`)); err != nil {
203-		cmd.Process.Kill()
204-		return
205-	}
206-
207-	app.harper = h
208-	go app.harperLoop()
209-}
210-
211-func (app *App) harperLoop() {
212-	h := app.harper
213-	opened := false
214-	for {
215-		msg, err := h.readMessage()
216-		if err != nil {
217-			app.queueStatusLine("", ui.Line{
218-				Head: ui.PlainString("--"),
219-				Body: ui.PlainString("spell checker error: " + err.Error()),
220-			})
221-			return
222-		}
223-		var envelope lspMessage
224-		if err := json.Unmarshal(msg, &envelope); err != nil {
225-			continue
226-		}
227-		if envelope.ID != nil && envelope.Method != "" {
228-			switch envelope.Method {
229-			case "workspace/configuration":
230-				cfg, _ := json.Marshal([]harperConfig{{HarperLS: harperLSConfig{Linters: harperDisabledLinters, UserDictPath: h.dictPath}}})
231-				h.sendResponse(envelope.ID, cfg)
232-				if !opened {
233-					opened = true
234-					h.sendNotification("textDocument/didOpen", json.RawMessage(`{
235-						"textDocument": {
236-							"uri": "file:///senpai-input.txt",
237-							"languageId": "plaintext",
238-							"version": 0,
239-							"text": ""
240-						}
241-					}`))
242-				}
243-			default:
244-				h.sendResponse(envelope.ID, json.RawMessage(`null`))
245-			}
246-			continue
247-		}
248-		if envelope.Method != "textDocument/publishDiagnostics" {
249-			continue
250-		}
251-		var params lspDiagnosticsParams
252-		if err := json.Unmarshal(envelope.Params, &params); err != nil {
253-			continue
254-		}
255-		var typos []events.TypoRange
256-		for _, d := range params.Diagnostics {
257-			typos = append(typos, events.TypoRange{
258-				Start: d.Range.Start.Character,
259-				End:   d.Range.End.Character,
260-			})
261-		}
262-		app.postEvent(event{
263-			src:     "*",
264-			content: &events.EventSpellCheck{Typos: typos},
265-		})
266-	}
267-}
268-
269-func (app *App) spellCheck() {
270-	if app.harper == nil {
271-		return
272-	}
273-	input := app.win.InputContent()
274-	if len(input) == 0 {
275-		app.win.SetTypos(nil)
276-		return
277-	}
278-	if isCommand(input) {
279-		app.win.SetTypos(nil)
280-		return
281-	}
282-	app.updateHarperDict()
283-	app.harper.sendChange(string(input))
284-}
285-
286-func (app *App) updateHarperDict() {
287-	netID, _ := app.win.CurrentBuffer()
288-	s := app.sessions[netID]
289-	if s == nil {
290-		return
291-	}
292-	words := s.Users()
293-	slices.Sort(words)
294-	content := strings.Join(words, "\n")
295-	if content == app.harper.dictPrev {
296-		return
297-	}
298-	app.harper.dictPrev = content
299-	os.WriteFile(app.harper.dictPath, []byte(content), 0600)
300-}
301-
302-func (app *App) harperClose() {
303-	if app.harper == nil {
304-		return
305-	}
306-	os.Remove(app.harper.dictPath)
307-	app.harper.sendNotification("shutdown", json.RawMessage(`null`))
308-	close(app.harper.writes)
309-	app.harper.cmd.Process.Kill()
310-}
311-
312-func (h *harperState) sendChange(text string) {
313-	select {
314-	case <-h.textCh:
315-	default:
316-	}
317-	h.textCh <- text
318-}
319-
320-func (h *harperState) sendRequest(method string, params json.RawMessage) error {
321-	h.reqID++
322-	msg, _ := json.Marshal(map[string]interface{}{
323-		"jsonrpc": "2.0",
324-		"id":      h.reqID,
325-		"method":  method,
326-		"params":  params,
327-	})
328-	return h.writeMessage(msg)
329-}
330-
331-func (h *harperState) sendResponse(id json.RawMessage, result json.RawMessage) error {
332-	msg, _ := json.Marshal(map[string]interface{}{
333-		"jsonrpc": "2.0",
334-		"id":      id,
335-		"result":  result,
336-	})
337-	return h.writeMessage(msg)
338-}
339-
340-func (h *harperState) sendNotification(method string, params json.RawMessage) error {
341-	msg, _ := json.Marshal(map[string]interface{}{
342-		"jsonrpc": "2.0",
343-		"method":  method,
344-		"params":  params,
345-	})
346-	return h.writeMessage(msg)
347-}
348-
349-func (h *harperState) writeMessage(msg []byte) error {
350-	header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
351-	h.writes <- append([]byte(header), msg...)
352-	return nil
353-}
354-
355-func (h *harperState) writeLoop() {
356-	for {
357-		select {
358-		case msg, ok := <-h.writes:
359-			if !ok {
360-				return
361-			}
362-
363-			if _, err := h.stdin.Write(msg); err != nil {
364-				return
365-			}
366-		case text := <-h.textCh:
367-			h.ver++
368-			params, _ := json.Marshal(map[string]interface{}{
369-				"textDocument": map[string]interface{}{
370-					"uri":     "file:///senpai-input.txt",
371-					"version": h.ver,
372-				},
373-				"contentChanges": []map[string]interface{}{
374-					{"text": text},
375-				},
376-			})
377-			msg, _ := json.Marshal(map[string]interface{}{
378-				"jsonrpc": "2.0",
379-				"method":  "textDocument/didChange",
380-				"params":  json.RawMessage(params),
381-			})
382-
383-			header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(msg))
384-			if _, err := h.stdin.Write(append([]byte(header), msg...)); err != nil {
385-				return
386-			}
387-			// Wait for the config response before accepting more text,
388-			// so we throttle to harper's processing speed.
389-			resp, ok := <-h.writes
390-			if !ok {
391-				return
392-			}
393-
394-			if _, err := h.stdin.Write(resp); err != nil {
395-				return
396-			}
397-		}
398-	}
399-}
400-
401-func (h *harperState) readMessage() ([]byte, error) {
402-	var contentLength int
403-	for {
404-		line, err := h.stdout.ReadString('\n')
405-		if err != nil {
406-			return nil, err
407-		}
408-		line = line[:len(line)-1]
409-		if len(line) > 0 && line[len(line)-1] == '\r' {
410-			line = line[:len(line)-1]
411-		}
412-		if line == "" {
413-			break
414-		}
415-		if len(line) > 16 && line[:16] == "Content-Length: " {
416-			contentLength, _ = strconv.Atoi(line[16:])
417-		}
418-	}
419-	if contentLength == 0 {
420-		return nil, fmt.Errorf("missing content length")
421-	}
422-	body := make([]byte, contentLength)
423-	if _, err := io.ReadFull(h.stdout, body); err != nil {
424-		return nil, err
425-	}
426-	return body, nil
427-}
428-
429-func utf16OffsetToRuneIndex(s string, utf16Offset int) int {
430-	runeIdx := 0
431-	utf16Pos := 0
432-	for _, r := range s {
433-		if utf16Pos >= utf16Offset {
434-			return runeIdx
435-		}
436-		utf16Pos += len(utf16.Encode([]rune{r}))
437-		runeIdx++
438-	}
439-	return runeIdx
440-}
+0, -17
 1@@ -3,7 +3,6 @@ package ui
 2 import (
 3 	"strings"
 4 
 5-	"git.sr.ht/~delthas/senpai/events"
 6 	"git.sr.ht/~rockorager/vaxis"
 7 )
 8 
 9@@ -74,8 +73,6 @@ type Editor struct {
10 	// oldest (lowest) index in text of lines that were changed.
11 	// used as an optimization to reduce copying when flushing lines.
12 	oldestTextChange int
13-
14-	typos []events.TypoRange
15 }
16 
17 // NewEditor returns a new Editor.
18@@ -326,7 +323,6 @@ func (e *Editor) Flush() string {
19 	e.cursorIdx = 0
20 	e.offsetIdx = 0
21 	e.autoCache = nil
22-	e.typos = nil
23 	e.backsearchEnd()
24 	e.oldestTextChange = len(e.text) - 1
25 	return content
26@@ -586,10 +582,6 @@ func (e *Editor) bumpOldestChange() {
27 	}
28 }
29 
30-func (e *Editor) SetTypos(typos []events.TypoRange) {
31-	e.typos = typos
32-}
33-
34 func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
35 	var st vaxis.Style
36 
37@@ -623,15 +615,6 @@ func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
38 		if i >= autoStart && i < autoEnd {
39 			s.UnderlineStyle = vaxis.UnderlineSingle
40 		}
41-		if s.UnderlineStyle == vaxis.UnderlineOff {
42-			for _, t := range e.typos {
43-				if i >= t.Start && i < t.End {
44-					s.UnderlineStyle = vaxis.UnderlineCurly
45-					s.UnderlineColor = e.ui.config.Colors.Gray
46-					break
47-				}
48-			}
49-		}
50 		if i == autoStart {
51 			autoX = x
52 		}
+0, -4
 1@@ -627,10 +627,6 @@ func (ui *UI) InputRune(r rune) {
 2 	ui.e.PutRune(r)
 3 }
 4 
 5-func (ui *UI) SetTypos(typos []events.TypoRange) {
 6-	ui.e.SetTypos(typos)
 7-}
 8-
 9 // InputEnter returns true if the event was eaten
10 func (ui *UI) InputEnter() bool {
11 	return ui.e.Enter()