Skip to content

Commit 4a0f0a5

Browse files
torrmalclaude
andcommitted
Fix task editor keybindings, mouse selection, and missing header
- Enter now submits and Shift+Enter inserts a newline (via xterm modifyOtherKeys / Kitty keyboard protocol CSI u reporting, with plain Enter still working as a fallback where unsupported). - Stop enabling terminal mouse tracking, which was hijacking native click-drag text selection/copy; drop the click-to-position feature that depended on it. - Show "YoloCoder" branding, a static robot face, and the current folder in the editor header itself, since the editor's alternate screen buffer was hiding the branding/folder line main.go prints beforehand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ff37167 commit 4a0f0a5

3 files changed

Lines changed: 113 additions & 85 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ yolocoder "Add expiration support to user sessions"
99
At startup, YoloCoder prints the current folder it will work in before mapping
1010
the repository.
1111

12-
Running `yolocoder` without a task opens a multiline terminal editor. Paste
13-
multiline instructions normally, use arrow keys or the mouse to reposition the
14-
cursor, press Enter for a new line, and press Ctrl+D to submit.
12+
Running `yolocoder` without a task opens a multiline terminal editor showing
13+
the current folder. Paste multiline instructions normally, use arrow keys to
14+
reposition the cursor, press Enter to submit, and press Shift+Enter for a new
15+
line (falls back to Enter-only in terminals that don't report Shift+Enter
16+
separately).
1517

1618
## Install
1719

‎internal/terminal/editor.go‎

Lines changed: 81 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"io"
8-
"strconv"
8+
"os"
99
"strings"
1010
"unicode/utf8"
1111

@@ -14,7 +14,7 @@ import (
1414

1515
var ErrEditorCancelled = errors.New("task entry cancelled")
1616

17-
const editorContentRow = 4
17+
const editorContentRow = 5
1818

1919
type textBuffer struct {
2020
text []rune
@@ -116,25 +116,28 @@ func (reader *Reader) EditTask(output io.Writer) (string, error) {
116116
}
117117
defer term.Restore(int(reader.input.Fd()), state)
118118

119-
fmt.Fprint(output, "\x1b[?1049h\x1b[?2004h\x1b[?1000h\x1b[?1006h")
120-
defer fmt.Fprint(output, "\x1b[?1006l\x1b[?1000l\x1b[?2004l\x1b[?1049l")
119+
// >4;2m and >1u ask the terminal to report Shift+Enter distinctly from
120+
// Enter (xterm modifyOtherKeys and the Kitty keyboard protocol,
121+
// respectively); terminals that don't understand them ignore them.
122+
fmt.Fprint(output, "\x1b[?1049h\x1b[?2004h\x1b[>4;2m\x1b[>1u")
123+
defer fmt.Fprint(output, "\x1b[<u\x1b[>4;0m\x1b[?2004l\x1b[?1049l")
121124

125+
folder := workingDirectory()
122126
buffer := &textBuffer{}
123127
for {
124-
drawEditor(output, buffer)
128+
drawEditor(output, buffer, folder)
125129
key, err := reader.buffer.ReadByte()
126130
if err != nil {
127131
return "", ErrEditorCancelled
128132
}
129133
switch key {
130134
case 3:
131135
return "", ErrEditorCancelled
132-
case 4:
133-
task := strings.TrimSpace(string(buffer.text))
134-
if task != "" {
136+
case 4, '\r':
137+
if task, ok := submit(buffer); ok {
135138
return task, nil
136139
}
137-
case '\r', '\n':
140+
case '\n':
138141
buffer.insert("\n")
139142
case 8, 127:
140143
buffer.backspace()
@@ -143,9 +146,15 @@ func (reader *Reader) EditTask(output io.Writer) (string, error) {
143146
case 5:
144147
buffer.end()
145148
case 27:
146-
if err := handleEditorEscape(reader.buffer, buffer); err != nil {
149+
enter, err := handleEditorEscape(reader.buffer, buffer)
150+
if err != nil {
147151
return "", err
148152
}
153+
if enter {
154+
if task, ok := submit(buffer); ok {
155+
return task, nil
156+
}
157+
}
149158
default:
150159
character, err := readRune(reader.buffer, key)
151160
if err == nil && character >= ' ' {
@@ -155,10 +164,29 @@ func (reader *Reader) EditTask(output io.Writer) (string, error) {
155164
}
156165
}
157166

158-
func drawEditor(output io.Writer, buffer *textBuffer) {
167+
func submit(buffer *textBuffer) (string, bool) {
168+
task := strings.TrimSpace(string(buffer.text))
169+
return task, task != ""
170+
}
171+
172+
func workingDirectory() string {
173+
directory, err := os.Getwd()
174+
if err != nil {
175+
return ""
176+
}
177+
if home, err := os.UserHomeDir(); err == nil {
178+
if relative, ok := strings.CutPrefix(directory, home); ok {
179+
directory = "~" + relative
180+
}
181+
}
182+
return directory
183+
}
184+
185+
func drawEditor(output io.Writer, buffer *textBuffer, folder string) {
159186
fmt.Fprint(output, "\x1b[H\x1b[2J")
187+
fmt.Fprintf(output, "\x1b[36m[^_^] YoloCoder\x1b[0m \x1b[2m%s\x1b[0m\r\n", folder)
160188
fmt.Fprint(output, "Describe the coding task\r\n")
161-
fmt.Fprint(output, "\x1b[2mEnter: new line • Ctrl+D: submit • Ctrl+C: cancel • arrows/click: move cursor\x1b[0m\r\n\r\n")
189+
fmt.Fprint(output, "\x1b[2mEnter: send • Shift+Enter: new line • Ctrl+C: cancel • arrows: move cursor\x1b[0m\r\n\r\n")
162190
lines := strings.Split(string(buffer.text), "\n")
163191
for _, line := range lines {
164192
fmt.Fprintf(output, "\x1b[36m│\x1b[0m %s\x1b[K\r\n", line)
@@ -167,16 +195,31 @@ func drawEditor(output io.Writer, buffer *textBuffer) {
167195
fmt.Fprintf(output, "\x1b[%d;%dH", editorContentRow+row, 3+column)
168196
}
169197

170-
func handleEditorEscape(reader *bufio.Reader, buffer *textBuffer) error {
198+
// handleEditorEscape parses one CSI sequence following an ESC byte and
199+
// applies it to buffer. It reports enter=true when the sequence represents
200+
// a plain Enter key reported through the CSI u keyboard protocol.
201+
func handleEditorEscape(reader *bufio.Reader, buffer *textBuffer) (bool, error) {
171202
next, err := reader.ReadByte()
172-
if err != nil || next != '[' {
173-
return nil
203+
if err != nil {
204+
return false, ErrEditorCancelled
174205
}
175-
code, err := reader.ReadByte()
206+
if next != '[' {
207+
return false, nil
208+
}
209+
first, err := reader.ReadByte()
176210
if err != nil {
177-
return ErrEditorCancelled
211+
return false, ErrEditorCancelled
212+
}
213+
var params strings.Builder
214+
final := first
215+
for final < 0x40 || final > 0x7e {
216+
params.WriteByte(final)
217+
final, err = reader.ReadByte()
218+
if err != nil {
219+
return false, ErrEditorCancelled
220+
}
178221
}
179-
switch code {
222+
switch final {
180223
case 'A':
181224
buffer.moveVertical(-1)
182225
case 'B':
@@ -189,44 +232,36 @@ func handleEditorEscape(reader *bufio.Reader, buffer *textBuffer) error {
189232
buffer.home()
190233
case 'F':
191234
buffer.end()
192-
case '3':
193-
if terminator, _ := reader.ReadByte(); terminator == '~' {
235+
case '~':
236+
switch params.String() {
237+
case "3":
194238
buffer.delete()
195-
}
196-
case '2':
197-
sequence, err := readControlSequence(reader, '~')
198-
if err != nil {
199-
return err
200-
}
201-
if sequence == "00" {
239+
case "200":
202240
pasted, err := readBracketedPaste(reader)
203241
if err != nil {
204-
return err
242+
return false, err
205243
}
206244
buffer.insert(pasted)
207245
}
208-
case '<':
209-
sequence, err := readMouseSequence(reader)
210-
if err != nil {
211-
return err
212-
}
213-
positionMouse(buffer, sequence)
246+
case 'u':
247+
return handleKeyboardProtocol(params.String(), buffer), nil
214248
}
215-
return nil
249+
return false, nil
216250
}
217251

218-
func readControlSequence(reader *bufio.Reader, terminator byte) (string, error) {
219-
var sequence strings.Builder
220-
for {
221-
character, err := reader.ReadByte()
222-
if err != nil {
223-
return "", ErrEditorCancelled
224-
}
225-
if character == terminator {
226-
return sequence.String(), nil
227-
}
228-
sequence.WriteByte(character)
252+
// handleKeyboardProtocol interprets a CSI u sequence ("codepoint;modifiers")
253+
// sent by terminals that support xterm's modifyOtherKeys or the Kitty
254+
// keyboard protocol. It reports enter=true for a plain Enter key press.
255+
func handleKeyboardProtocol(params string, buffer *textBuffer) bool {
256+
codepoint, modifiers, _ := strings.Cut(params, ";")
257+
if codepoint != "13" {
258+
return false
259+
}
260+
if modifiers == "2" {
261+
buffer.insert("\n")
262+
return false
229263
}
264+
return true
230265
}
231266

232267
func readBracketedPaste(reader *bufio.Reader) (string, error) {
@@ -250,36 +285,6 @@ func readBracketedPaste(reader *bufio.Reader) (string, error) {
250285
}
251286
}
252287

253-
func readMouseSequence(reader *bufio.Reader) (string, error) {
254-
var sequence strings.Builder
255-
for {
256-
character, err := reader.ReadByte()
257-
if err != nil {
258-
return "", ErrEditorCancelled
259-
}
260-
sequence.WriteByte(character)
261-
if character == 'M' || character == 'm' {
262-
return sequence.String(), nil
263-
}
264-
}
265-
}
266-
267-
func positionMouse(buffer *textBuffer, sequence string) {
268-
if !strings.HasSuffix(sequence, "M") {
269-
return
270-
}
271-
fields := strings.Split(strings.TrimSuffix(sequence, "M"), ";")
272-
if len(fields) != 3 || fields[0] != "0" {
273-
return
274-
}
275-
column, columnErr := strconv.Atoi(fields[1])
276-
row, rowErr := strconv.Atoi(fields[2])
277-
if columnErr != nil || rowErr != nil || row < editorContentRow {
278-
return
279-
}
280-
buffer.setPosition(row-editorContentRow, column-3)
281-
}
282-
283288
func readRune(reader *bufio.Reader, first byte) (rune, error) {
284289
if first < utf8.RuneSelf {
285290
return rune(first), nil

‎internal/terminal/editor_test.go‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,33 @@ func TestReadBracketedPastePreservesNewlines(t *testing.T) {
3838
}
3939
}
4040

41-
func TestPositionMouseMovesCursor(t *testing.T) {
42-
buffer := &textBuffer{text: []rune("first\nsecond")}
43-
positionMouse(buffer, "0;5;5M")
44-
row, column := buffer.position()
45-
if row != 1 || column != 2 {
46-
t.Fatalf("position = %d,%d", row, column)
41+
func TestHandleKeyboardProtocolShiftEnterInsertsNewline(t *testing.T) {
42+
buffer := &textBuffer{text: []rune("hi"), cursor: 2}
43+
if handleKeyboardProtocol("13;2", buffer) {
44+
t.Fatal("shift+enter should not report enter")
45+
}
46+
if string(buffer.text) != "hi\n" {
47+
t.Fatalf("buffer text = %q", buffer.text)
48+
}
49+
}
50+
51+
func TestHandleKeyboardProtocolPlainEnterReportsEnter(t *testing.T) {
52+
buffer := &textBuffer{text: []rune("hi")}
53+
if !handleKeyboardProtocol("13", buffer) {
54+
t.Fatal("plain enter should report enter")
55+
}
56+
if string(buffer.text) != "hi" {
57+
t.Fatalf("buffer text = %q", buffer.text)
58+
}
59+
}
60+
61+
func TestHandleKeyboardProtocolIgnoresOtherKeys(t *testing.T) {
62+
buffer := &textBuffer{text: []rune("hi")}
63+
if handleKeyboardProtocol("65", buffer) {
64+
t.Fatal("non-enter key should not report enter")
65+
}
66+
if string(buffer.text) != "hi" {
67+
t.Fatalf("buffer text = %q", buffer.text)
4768
}
4869
}
4970

0 commit comments

Comments
 (0)