From 610a815f5396218c60da6bc4917c41ccec60f21c Mon Sep 17 00:00:00 2001 From: atinylittleshell Date: Sat, 14 Mar 2026 11:35:54 -0700 Subject: [PATCH] feat(repl): add OSC 7 support for terminal cwd --- AGENTS.md | 4 +++ internal/repl/osc.go | 23 ++++++++++++++++++ internal/repl/osc_test.go | 51 +++++++++++++++++++++++++++++++++++++++ internal/repl/repl.go | 9 +++++++ 4 files changed, 87 insertions(+) create mode 100644 internal/repl/osc.go create mode 100644 internal/repl/osc_test.go diff --git a/AGENTS.md b/AGENTS.md index 741bbd4..0fabfb6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,10 @@ model exampleModel { } ``` +## Test-Driven Development + +Always use TDD when developing new features. Write tests first based on the expected behavior (e.g., spec, protocol, or requirements), then write the implementation to make them pass. Writing tests after the implementation risks asserting what the code does rather than what it should do, which lets bugs slip through. + ## Testing the binary If you need to build the gsh binary for testing, you can just run `make build` or a custom build command outputting to `./bin/gsh`. diff --git a/internal/repl/osc.go b/internal/repl/osc.go new file mode 100644 index 0000000..957c355 --- /dev/null +++ b/internal/repl/osc.go @@ -0,0 +1,23 @@ +package repl + +import ( + "fmt" + "io" + "net/url" + "strings" +) + +// emitOSC7 writes an OSC 7 escape sequence to inform the terminal of the +// current working directory. This allows terminal emulators (Terminal.app, +// iTerm2, etc.) to open new tabs/windows in the same directory. +// Format: ESC ] 7 ; file://HOSTNAME/url-encoded-path ESC \ +func emitOSC7(w io.Writer, hostname, dir string) { + // Encode each path segment individually to preserve '/' separators. + // url.PathEscape encodes '/' to '%2F' which breaks the file URL. + segments := strings.Split(dir, "/") + for i, seg := range segments { + segments[i] = url.PathEscape(seg) + } + encodedPath := strings.Join(segments, "/") + fmt.Fprintf(w, "\033]7;file://%s%s\033\\", hostname, encodedPath) +} diff --git a/internal/repl/osc_test.go b/internal/repl/osc_test.go new file mode 100644 index 0000000..88ff731 --- /dev/null +++ b/internal/repl/osc_test.go @@ -0,0 +1,51 @@ +package repl + +import ( + "bytes" + "testing" +) + +func TestEmitOSC7(t *testing.T) { + tests := []struct { + name string + hostname string + dir string + expected string + }{ + { + name: "normal path", + hostname: "myhost", + dir: "/Users/test/projects", + expected: "\033]7;file://myhost/Users/test/projects\033\\", + }, + { + name: "path with spaces", + hostname: "myhost", + dir: "/Users/test/my projects", + expected: "\033]7;file://myhost/Users/test/my%20projects\033\\", + }, + { + name: "path with hash", + hostname: "myhost", + dir: "/Users/test/project#1", + expected: "\033]7;file://myhost/Users/test/project%231\033\\", + }, + { + name: "empty hostname", + hostname: "", + dir: "/Users/test", + expected: "\033]7;file:///Users/test\033\\", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + emitOSC7(&buf, tt.hostname, tt.dir) + got := buf.String() + if got != tt.expected { + t.Errorf("emitOSC7(%q, %q) = %q, want %q", tt.hostname, tt.dir, got, tt.expected) + } + }) + } +} diff --git a/internal/repl/repl.go b/internal/repl/repl.go index baafc91..3f51501 100644 --- a/internal/repl/repl.go +++ b/internal/repl/repl.go @@ -263,6 +263,9 @@ func (r *REPL) setSigintChannelFactory(factory func() (chan os.Signal, func())) func (r *REPL) Run(ctx context.Context) error { r.logger.Info("starting REPL") + // Cache hostname for OSC 7 escape sequences (terminal CWD tracking) + hostname, _ := os.Hostname() + // Emit repl.ready event (welcome screen is handled by event handler in defaults/events/repl.gsh) r.executor.Interpreter().EmitEvent(interpreter.EventReplReady, interpreter.CreateReplReadyContext()) @@ -292,6 +295,12 @@ func (r *REPL) Run(ctx context.Context) error { default: } + // Emit OSC 7 to tell the terminal the current working directory + // (enables new tabs/windows to open in the same directory) + if dir := r.executor.GetPwd(); dir != "" && term.IsTerminal(int(os.Stdout.Fd())) { + emitOSC7(os.Stdout, hostname, dir) + } + // Get prompt - emits repl.prompt event internally prompt := r.getPrompt()