first commit
This commit is contained in:
13
cli/internal/runtime/notice.go
Normal file
13
cli/internal/runtime/notice.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package runtime
|
||||
|
||||
import "github.com/maximhq/vt10x"
|
||||
|
||||
// TabNoticeLevel controls how transient tab bar notices are styled.
|
||||
type TabNoticeLevel int
|
||||
|
||||
const (
|
||||
TabNoticeInfo TabNoticeLevel = iota
|
||||
TabNoticeError
|
||||
)
|
||||
|
||||
const hostTrackedVTModeMask = vt10x.ModeMouseMask | vt10x.ModeMouseSgr | vt10x.ModeFocus
|
||||
94
cli/internal/runtime/pty.go
Normal file
94
cli/internal/runtime/pty.go
Normal file
@@ -0,0 +1,94 @@
|
||||
//go:build !windows
|
||||
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/creack/pty"
|
||||
"golang.org/x/term"
|
||||
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// runWithPTY starts cmd attached to a new pseudo-terminal and relays I/O
|
||||
// between the outer terminal and the PTY master. This lets TUI apps render
|
||||
// correctly while bifrost retains control of the process.
|
||||
func runWithPTY(ctx context.Context, stdout io.Writer, cmd *exec.Cmd) error {
|
||||
// Get the initial terminal size from the real terminal
|
||||
sz, err := pty.GetsizeFull(os.Stdin)
|
||||
if err != nil {
|
||||
// Fallback to a reasonable default if stdin isn't a terminal
|
||||
sz = &pty.Winsize{Rows: 24, Cols: 80}
|
||||
}
|
||||
|
||||
// Start the command with a PTY attached, sized to match the outer terminal
|
||||
ptmx, err := pty.StartWithSize(cmd, sz)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ptmx.Close()
|
||||
|
||||
// Handle SIGWINCH — propagate terminal resizes to the PTY
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGWINCH)
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
signal.Stop(sigCh)
|
||||
close(done)
|
||||
}()
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-sigCh:
|
||||
if newSz, err := pty.GetsizeFull(os.Stdin); err == nil {
|
||||
_ = pty.Setsize(ptmx, newSz)
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Put the outer terminal into raw mode so keystrokes (Ctrl-C, etc.)
|
||||
// are forwarded as bytes to the PTY rather than handled by the OS.
|
||||
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
||||
if err != nil {
|
||||
// If we can't go raw (e.g., piped input), continue without it
|
||||
oldState = nil
|
||||
}
|
||||
if oldState != nil {
|
||||
defer func() {
|
||||
_ = term.Restore(int(os.Stdin.Fd()), oldState)
|
||||
_, _ = io.WriteString(stdout, hostCursorResetSequence())
|
||||
}()
|
||||
}
|
||||
|
||||
// Relay stdout: PTY master → caller's stdout
|
||||
// This goroutine exits when the child process dies and the PTY master
|
||||
// returns EOF.
|
||||
outDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(outDone)
|
||||
_, _ = io.Copy(stdout, ptmx)
|
||||
}()
|
||||
|
||||
// Relay stdin: outer terminal → PTY master
|
||||
// This goroutine will block on os.Stdin.Read after the child exits;
|
||||
// that's expected and harmless — it unblocks on the next keystroke.
|
||||
go func() {
|
||||
_, _ = io.Copy(ptmx, os.Stdin)
|
||||
}()
|
||||
|
||||
// Wait for the command to finish
|
||||
err = cmd.Wait()
|
||||
|
||||
// Drain any remaining PTY output
|
||||
<-outDone
|
||||
|
||||
return err
|
||||
}
|
||||
20
cli/internal/runtime/pty_windows.go
Normal file
20
cli/internal/runtime/pty_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// runWithPTY is a no-op on Windows — falls back to direct stdin/stdout piping.
|
||||
// Windows does not support POSIX pseudo-terminals; full support would require
|
||||
// ConPTY which is not yet portable in Go.
|
||||
func runWithPTY(_ context.Context, stdout io.Writer, cmd *exec.Cmd) error {
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stdout
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
240
cli/internal/runtime/replay_test.go
Normal file
240
cli/internal/runtime/replay_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/maximhq/vt10x"
|
||||
)
|
||||
|
||||
type replayFixture struct {
|
||||
Cols int `json:"cols"`
|
||||
Rows int `json:"rows"`
|
||||
Chunks []string `json:"chunks"`
|
||||
Snapshots []replaySnapshot `json:"snapshots"`
|
||||
}
|
||||
|
||||
type replaySnapshot struct {
|
||||
AfterChunk int `json:"after_chunk"`
|
||||
Screen []string `json:"screen"`
|
||||
}
|
||||
|
||||
func TestOpencodeScrollReplayFixture(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixture := loadReplayFixture(t, "testdata/opencode_scroll_replay.json")
|
||||
term := vt10x.New(vt10x.WithSize(fixture.Cols, fixture.Rows))
|
||||
var normalizer vtStreamNormalizer
|
||||
|
||||
checkpoints := make(map[int][]string, len(fixture.Snapshots))
|
||||
for _, snapshot := range fixture.Snapshots {
|
||||
checkpoints[snapshot.AfterChunk] = snapshot.Screen
|
||||
}
|
||||
|
||||
for i, chunk := range fixture.Chunks {
|
||||
data := normalizer.Normalize([]byte(chunk))
|
||||
if len(data) > 0 {
|
||||
if _, err := term.Write(data); err != nil {
|
||||
t.Fatalf("write chunk %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
if want, ok := checkpoints[i+1]; ok {
|
||||
if got := snapshotScreen(term, fixture.Cols, fixture.Rows); !equalLines(got, want) {
|
||||
t.Fatalf("snapshot after chunk %d mismatch\nwant:\n%s\n\ngot:\n%s", i+1, strings.Join(want, "\n"), strings.Join(got, "\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVTStreamNormalizerHandlesSplitTrueColorSGR(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
term := vt10x.New(vt10x.WithSize(8, 1))
|
||||
var normalizer vtStreamNormalizer
|
||||
|
||||
if data := normalizer.Normalize([]byte("\x1b[38:2::100")); len(data) != 0 {
|
||||
t.Fatalf("expected incomplete chunk to be buffered, got %q", string(data))
|
||||
}
|
||||
data := normalizer.Normalize([]byte(":150:200mHi"))
|
||||
if _, err := term.Write(data); err != nil {
|
||||
t.Fatalf("write normalized chunk: %v", err)
|
||||
}
|
||||
|
||||
term.Lock()
|
||||
defer term.Unlock()
|
||||
|
||||
cell := term.Cell(0, 0)
|
||||
if cell.Char != 'H' {
|
||||
t.Fatalf("expected first cell to contain H, got %q", cell.Char)
|
||||
}
|
||||
if want := vt10x.Color(100<<16 | 150<<8 | 200); cell.FG != want {
|
||||
t.Fatalf("expected truecolor fg %v, got %v", want, cell.FG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVTAlternateScreenRestore(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
term := vt10x.New(vt10x.WithSize(10, 3))
|
||||
var normalizer vtStreamNormalizer
|
||||
|
||||
writeReplayChunk(t, term, &normalizer, "main")
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[?1049h\x1b[2J\x1b[Halt")
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[?1049l")
|
||||
|
||||
got := snapshotScreen(term, 10, 3)
|
||||
if got[0] != "main " {
|
||||
t.Fatalf("expected main screen to be restored, got %q", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVTWritesCursorPositionRepliesToConfiguredWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var out bytes.Buffer
|
||||
term := vt10x.New(
|
||||
vt10x.WithWriter(&out),
|
||||
vt10x.WithSize(10, 3),
|
||||
)
|
||||
|
||||
if _, err := term.Write([]byte("\x1b[6n")); err != nil {
|
||||
t.Fatalf("write cpr request: %v", err)
|
||||
}
|
||||
|
||||
if got := out.String(); got != "\x1b[1;1R" {
|
||||
t.Fatalf("expected CPR reply %q, got %q", "\x1b[1;1R", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractCursorVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := extractCursorVisible([]byte("hello")); got != -1 {
|
||||
t.Fatalf("extractCursorVisible(no toggle) = %d, want -1", got)
|
||||
}
|
||||
if got := extractCursorVisible([]byte("\x1b[?25h")); got != 1 {
|
||||
t.Fatalf("extractCursorVisible(show) = %d, want 1", got)
|
||||
}
|
||||
if got := extractCursorVisible([]byte("\x1b[?25l")); got != 0 {
|
||||
t.Fatalf("extractCursorVisible(hide) = %d, want 0", got)
|
||||
}
|
||||
if got := extractCursorVisible([]byte("\x1b[?25h...\x1b[?25l")); got != 0 {
|
||||
t.Fatalf("extractCursorVisible(last wins) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizerDropsKittyKeyboardProtocol(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
term := vt10x.New(vt10x.WithSize(20, 5))
|
||||
var normalizer vtStreamNormalizer
|
||||
|
||||
// Write text, move cursor to row 3, then send Kitty keyboard sequences.
|
||||
// Without the fix, \x1b[>1u would be misinterpreted as DECRC (cursor
|
||||
// restore) and snap the cursor back to (0,0).
|
||||
writeReplayChunk(t, term, &normalizer, "hello") // cursor at (5, 0)
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[4;1H") // move to row 4, col 1
|
||||
writeReplayChunk(t, term, &normalizer, "world") // cursor at (5, 3)
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[>1u") // push kitty keyboard mode
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[?u") // query kitty keyboard mode
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[<u") // pop kitty keyboard mode
|
||||
writeReplayChunk(t, term, &normalizer, "\x1b[=1;2u") // kitty with flags
|
||||
|
||||
term.Lock()
|
||||
cursor := term.Cursor()
|
||||
term.Unlock()
|
||||
|
||||
// Cursor should still be at (5, 3) — kitty sequences must not move it.
|
||||
if cursor.X != 5 || cursor.Y != 3 {
|
||||
t.Fatalf("expected cursor at (5, 3), got (%d, %d) — kitty sequences corrupted cursor state", cursor.X, cursor.Y)
|
||||
}
|
||||
|
||||
// Verify the text was written correctly.
|
||||
got := snapshotScreen(term, 20, 5)
|
||||
if !strings.HasPrefix(got[0], "hello") {
|
||||
t.Fatalf("expected 'hello' on row 0, got %q", got[0])
|
||||
}
|
||||
if !strings.HasPrefix(got[3], "world") {
|
||||
t.Fatalf("expected 'world' on row 3, got %q", got[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizerPassesThroughNormalCSI(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var normalizer vtStreamNormalizer
|
||||
|
||||
// Regular CSI sequences (\x1b[?1049h, \x1b[2J, \x1b[H) must pass through.
|
||||
data := normalizer.Normalize([]byte("\x1b[?1049h\x1b[2J\x1b[H"))
|
||||
if string(data) != "\x1b[?1049h\x1b[2J\x1b[H" {
|
||||
t.Fatalf("expected normal CSI to pass through, got %q", string(data))
|
||||
}
|
||||
|
||||
// Kitty keyboard protocol must be stripped.
|
||||
data = normalizer.Normalize([]byte("A\x1b[>1uB"))
|
||||
if string(data) != "AB" {
|
||||
t.Fatalf("expected kitty sequence to be stripped, got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func loadReplayFixture(t *testing.T, path string) replayFixture {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read replay fixture: %v", err)
|
||||
}
|
||||
|
||||
var fixture replayFixture
|
||||
if err := json.Unmarshal(data, &fixture); err != nil {
|
||||
t.Fatalf("unmarshal replay fixture: %v", err)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
func writeReplayChunk(t *testing.T, term vt10x.Terminal, normalizer *vtStreamNormalizer, chunk string) {
|
||||
t.Helper()
|
||||
|
||||
data := normalizer.Normalize([]byte(chunk))
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := term.Write(data); err != nil {
|
||||
t.Fatalf("write replay chunk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotScreen(term vt10x.Terminal, cols, rows int) []string {
|
||||
term.Lock()
|
||||
defer term.Unlock()
|
||||
|
||||
lines := make([]string, rows)
|
||||
for y := 0; y < rows; y++ {
|
||||
var line []rune
|
||||
for x := 0; x < cols; x++ {
|
||||
ch := term.Cell(x, y).Char
|
||||
if ch == 0 {
|
||||
ch = ' '
|
||||
}
|
||||
line = append(line, ch)
|
||||
}
|
||||
lines[y] = string(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func equalLines(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
163
cli/internal/runtime/runtime.go
Normal file
163
cli/internal/runtime/runtime.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/maximhq/bifrost/cli/internal/apis"
|
||||
"github.com/maximhq/bifrost/cli/internal/harness"
|
||||
)
|
||||
|
||||
// LaunchSpec holds the parameters needed to launch a harness subprocess.
|
||||
type LaunchSpec struct {
|
||||
Harness harness.Harness
|
||||
BaseURL string
|
||||
VirtualKey string
|
||||
Model string
|
||||
Worktree string // empty = no worktree, non-empty = worktree name (or " " for unnamed)
|
||||
}
|
||||
|
||||
// BuildEnv constructs the environment variables for the harness process,
|
||||
// including the provider endpoint, API key, and model overrides.
|
||||
func BuildEnv(spec LaunchSpec) ([]string, error) {
|
||||
endpoint, err := apis.BuildEndpoint(spec.BaseURL, spec.Harness.BasePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := os.Environ()
|
||||
env = append(env, spec.Harness.BaseURLEnv+"="+endpoint)
|
||||
|
||||
vk := strings.TrimSpace(spec.VirtualKey)
|
||||
if vk != "" {
|
||||
env = append(env, spec.Harness.APIKeyEnv+"="+vk)
|
||||
if spec.Harness.AuthTokenEnv != "" {
|
||||
env = append(env, spec.Harness.AuthTokenEnv+"="+vk)
|
||||
}
|
||||
}
|
||||
model := strings.TrimSpace(spec.Model)
|
||||
if model != "" {
|
||||
env = append(env, "BIFROST_MODEL="+model)
|
||||
if spec.Harness.ModelEnv != "" {
|
||||
env = append(env, spec.Harness.ModelEnv+"="+model)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session as running inside bifrost
|
||||
env = append(env, "BIFROST_SESSION=1")
|
||||
env = append(env, "BIFROST_BASE_URL="+spec.BaseURL)
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// PreparedCmd holds a command ready to execute along with any cleanup
|
||||
// function that should be called after the process exits.
|
||||
type PreparedCmd struct {
|
||||
Cmd *exec.Cmd
|
||||
Cleanup func()
|
||||
}
|
||||
|
||||
// PrepareCommand builds the exec.Cmd for a harness launch, including
|
||||
// environment variables, pre-launch hooks, and CLI arguments.
|
||||
func PrepareCommand(ctx context.Context, spec LaunchSpec) (*PreparedCmd, error) {
|
||||
env, err := BuildEnv(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cleanup func()
|
||||
if spec.Harness.PreLaunch != nil {
|
||||
endpoint, err := apis.BuildEndpoint(spec.BaseURL, spec.Harness.BasePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build endpoint for pre-launch: %w", err)
|
||||
}
|
||||
vk := strings.TrimSpace(spec.VirtualKey)
|
||||
if vk == "" {
|
||||
vk = "dummy-key"
|
||||
}
|
||||
extraEnv, c, err := spec.Harness.PreLaunch(endpoint, vk, spec.Model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pre-launch %s: %w", spec.Harness.Label, err)
|
||||
}
|
||||
cleanup = c
|
||||
env = append(env, extraEnv...)
|
||||
}
|
||||
|
||||
args := []string{}
|
||||
if spec.Harness.RunArgsForMod != nil {
|
||||
args = append(args, spec.Harness.RunArgsForMod(spec.Model)...)
|
||||
}
|
||||
if spec.Worktree != "" && spec.Harness.WorktreeArgs != nil {
|
||||
args = append(args, spec.Harness.WorktreeArgs(spec.Worktree)...)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, spec.Harness.Binary, args...)
|
||||
cmd.Env = env
|
||||
|
||||
return &PreparedCmd{Cmd: cmd, Cleanup: cleanup}, nil
|
||||
}
|
||||
|
||||
// RunInteractive launches the harness as an interactive subprocess with full
|
||||
// TTY access. It prints a bifrost banner before launch and a summary after exit.
|
||||
func RunInteractive(ctx context.Context, stdout, stderr io.Writer, spec LaunchSpec) error {
|
||||
p, err := PrepareCommand(ctx, spec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Cleanup != nil {
|
||||
defer p.Cleanup()
|
||||
}
|
||||
|
||||
fmt.Fprint(stdout, renderBanner(spec))
|
||||
|
||||
if err := runWithPTY(ctx, stdout, p.Cmd); err != nil {
|
||||
fmt.Fprintf(stdout, "\n\033[36mbifrost>\033[0m session ended with error: %v\n", err)
|
||||
return fmt.Errorf("run harness: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "\n\033[36mbifrost>\033[0m session ended\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderBanner builds the pre-launch info box showing harness, model,
|
||||
// endpoint, and the equivalent command.
|
||||
func renderBanner(spec LaunchSpec) string {
|
||||
endpoint, err := apis.BuildEndpoint(spec.BaseURL, spec.Harness.BasePath)
|
||||
if err != nil {
|
||||
endpoint = spec.BaseURL + " (invalid)"
|
||||
}
|
||||
|
||||
vkStatus := "no"
|
||||
if strings.TrimSpace(spec.VirtualKey) != "" {
|
||||
vkStatus = "yes"
|
||||
}
|
||||
|
||||
cmdLine := spec.Harness.Binary
|
||||
if spec.Harness.RunArgsForMod != nil {
|
||||
if a := spec.Harness.RunArgsForMod(spec.Model); len(a) > 0 {
|
||||
cmdLine += " " + strings.Join(a, " ")
|
||||
}
|
||||
}
|
||||
if spec.Worktree != "" && spec.Harness.WorktreeArgs != nil {
|
||||
if a := spec.Harness.WorktreeArgs(spec.Worktree); len(a) > 0 {
|
||||
cmdLine += " " + strings.Join(a, " ")
|
||||
}
|
||||
}
|
||||
|
||||
cyan := "\033[36m"
|
||||
dim := "\033[2m"
|
||||
bold := "\033[1m"
|
||||
reset := "\033[0m"
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\n")
|
||||
b.WriteString(dim + "───────────────────────────────────────────────────" + reset + "\n")
|
||||
b.WriteString(cyan + "bifrost>" + reset + " " + bold + spec.Harness.Label + reset + " " + dim + spec.Model + reset + "\n")
|
||||
b.WriteString(dim + " endpoint : " + reset + endpoint + "\n")
|
||||
b.WriteString(dim + " vk : " + reset + vkStatus + "\n")
|
||||
b.WriteString(dim + " command : " + reset + cmdLine + "\n")
|
||||
b.WriteString(dim + "───────────────────────────────────────────────────" + reset + "\n")
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
71
cli/internal/runtime/sgr.go
Normal file
71
cli/internal/runtime/sgr.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// rewriteSGRParams converts colon-separated SGR sub-parameters to semicolon-
|
||||
// separated equivalents. Each semicolon-delimited group is processed:
|
||||
//
|
||||
// - 4:x -> 4 (underline style -> basic underline)
|
||||
// - 38:2:cs:r:g:b -> 38;2;r;g;b (fg true-color, drop colorspace)
|
||||
// - 48:2:cs:r:g:b -> 48;2;r;g;b (bg true-color, drop colorspace)
|
||||
// - 38:5:n -> 38;5;n (fg 256-color)
|
||||
// - 48:5:n -> 48;5;n (bg 256-color)
|
||||
// - 58:... -> (dropped - underline color, unsupported by vt10x)
|
||||
// - other:x -> other (keep first sub-param only)
|
||||
func rewriteSGRParams(params []byte) []byte {
|
||||
parts := bytes.Split(params, []byte{';'})
|
||||
var out [][]byte
|
||||
for _, part := range parts {
|
||||
if !bytes.ContainsRune(part, ':') {
|
||||
out = append(out, part)
|
||||
continue
|
||||
}
|
||||
subs := bytes.Split(part, []byte{':'})
|
||||
if len(subs) == 0 {
|
||||
continue
|
||||
}
|
||||
code, err := strconv.Atoi(string(subs[0]))
|
||||
if err != nil {
|
||||
out = append(out, subs[0])
|
||||
continue
|
||||
}
|
||||
switch code {
|
||||
case 4: // underline style -> basic underline
|
||||
out = append(out, []byte("4"))
|
||||
case 38, 48: // fg/bg color
|
||||
if len(subs) >= 2 {
|
||||
switch string(subs[1]) {
|
||||
case "2": // true-color: code:2[:cs]:r:g:b
|
||||
// Find r,g,b - skip optional colorspace id.
|
||||
if len(subs) >= 6 {
|
||||
// code:2:cs:r:g:b
|
||||
out = append(out, subs[0], []byte("2"), subs[3], subs[4], subs[5])
|
||||
} else if len(subs) >= 5 {
|
||||
// code:2:r:g:b (no colorspace)
|
||||
out = append(out, subs[0], []byte("2"), subs[2], subs[3], subs[4])
|
||||
} else {
|
||||
out = append(out, subs[0])
|
||||
}
|
||||
case "5": // 256-color: code:5:n
|
||||
if len(subs) >= 3 {
|
||||
out = append(out, subs[0], []byte("5"), subs[2])
|
||||
} else {
|
||||
out = append(out, subs[0])
|
||||
}
|
||||
default:
|
||||
out = append(out, subs[0])
|
||||
}
|
||||
} else {
|
||||
out = append(out, subs[0])
|
||||
}
|
||||
case 58: // underline color - not supported by vt10x, drop entirely
|
||||
continue
|
||||
default:
|
||||
out = append(out, subs[0])
|
||||
}
|
||||
}
|
||||
return bytes.Join(out, []byte{';'})
|
||||
}
|
||||
2485
cli/internal/runtime/tabmgr.go
Normal file
2485
cli/internal/runtime/tabmgr.go
Normal file
File diff suppressed because it is too large
Load Diff
1032
cli/internal/runtime/tabmgr_test.go
Normal file
1032
cli/internal/runtime/tabmgr_test.go
Normal file
File diff suppressed because it is too large
Load Diff
39
cli/internal/runtime/tabmgr_windows.go
Normal file
39
cli/internal/runtime/tabmgr_windows.go
Normal file
@@ -0,0 +1,39 @@
|
||||
//go:build windows
|
||||
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ErrQuit is returned by RunTabbed when the user quits the chooser
|
||||
// without creating any tabs.
|
||||
var ErrQuit = errors.New("user quit")
|
||||
|
||||
// ErrBackToTabs is returned by the newTabFn when the user presses Ctrl+B
|
||||
// to dismiss the chooser and return to tab command mode.
|
||||
var ErrBackToTabs = errors.New("back to tabs")
|
||||
|
||||
// ErrUpdateRequested is returned by RunTabbed when the user presses U in
|
||||
// tab command mode to trigger a self-update and re-exec.
|
||||
var ErrUpdateRequested = errors.New("update requested")
|
||||
|
||||
// NewTabFunc is called when the user requests a new tab or reopens the
|
||||
// chooser for the active tab.
|
||||
// stdinReader provides keyboard input; when nil the callback should read os.Stdin.
|
||||
// tabBarLine returns the current tab bar content for embedding in the chooser view.
|
||||
type NewTabFunc func(ctx context.Context, notify func(level TabNoticeLevel, message string), tabBarLine func() string, stdinReader io.Reader, seed *LaunchSpec) (*LaunchSpec, error)
|
||||
|
||||
// RunTabbed is not supported on Windows — falls back to single-session mode.
|
||||
func RunTabbed(ctx context.Context, stdout, stderr io.Writer, version string, updateVersion string, newTabFn NewTabFunc) error {
|
||||
spec, err := newTabFn(ctx, func(TabNoticeLevel, string) {}, nil, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if spec == nil {
|
||||
return ErrQuit
|
||||
}
|
||||
return RunInteractive(ctx, stdout, stderr, *spec)
|
||||
}
|
||||
40
cli/internal/runtime/testdata/opencode_scroll_replay.json
vendored
Normal file
40
cli/internal/runtime/testdata/opencode_scroll_replay.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cols": 18,
|
||||
"rows": 6,
|
||||
"chunks": [
|
||||
"\u001b[?1049h\u001b[2J\u001b[H",
|
||||
"HEADER ",
|
||||
"\u001b[6;1HFOOTER ",
|
||||
"\u001b[2;",
|
||||
"5r\u001b[?6h\u001b[2;1H",
|
||||
"item 1\r\nitem 2\r\n",
|
||||
"item 3\r\nitem 4",
|
||||
"\r\nitem 5",
|
||||
"\r\nitem 6",
|
||||
"\u001b[?6l\u001b[r"
|
||||
],
|
||||
"snapshots": [
|
||||
{
|
||||
"after_chunk": 3,
|
||||
"screen": [
|
||||
"HEADER ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
" ",
|
||||
"FOOTER "
|
||||
]
|
||||
},
|
||||
{
|
||||
"after_chunk": 9,
|
||||
"screen": [
|
||||
"HEADER ",
|
||||
"item 3 ",
|
||||
"item 4 ",
|
||||
"item 5 ",
|
||||
"item 6 ",
|
||||
"FOOTER "
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
161
cli/internal/runtime/vtstream.go
Normal file
161
cli/internal/runtime/vtstream.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package runtime
|
||||
|
||||
import "bytes"
|
||||
|
||||
// vtStreamNormalizer preserves incomplete CSI sequences across PTY reads before
|
||||
// applying the SGR compatibility rewrite that vt10x still needs.
|
||||
type vtStreamNormalizer struct {
|
||||
pendingCSI []byte
|
||||
}
|
||||
|
||||
func (n *vtStreamNormalizer) Normalize(data []byte) []byte {
|
||||
if len(n.pendingCSI) > 0 {
|
||||
combined := make([]byte, 0, len(n.pendingCSI)+len(data))
|
||||
combined = append(combined, n.pendingCSI...)
|
||||
combined = append(combined, data...)
|
||||
data = combined
|
||||
n.pendingCSI = nil
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]byte, 0, len(data)+32)
|
||||
for i := 0; i < len(data); {
|
||||
if data[i] == 0x1b {
|
||||
if i+1 >= len(data) {
|
||||
n.pendingCSI = append(n.pendingCSI[:0], data[i:]...)
|
||||
break
|
||||
}
|
||||
if data[i+1] == '[' {
|
||||
start := i
|
||||
j := i + 2
|
||||
for j < len(data) && data[j] < 0x40 {
|
||||
j++
|
||||
}
|
||||
if j >= len(data) {
|
||||
n.pendingCSI = append(n.pendingCSI[:0], data[start:]...)
|
||||
break
|
||||
}
|
||||
// Drop CSI sequences that vt10x would misinterpret.
|
||||
// Sequences with intermediate bytes (>, <, =) are private-use
|
||||
// extensions (e.g. Kitty keyboard \x1b[>1u) that vt10x's CSI
|
||||
// parser misroutes. Also drop ?-prefixed sequences ending in
|
||||
// 'u' (\x1b[?u — Kitty keyboard query) which vt10x wrongly
|
||||
// dispatches as DECRC (cursor restore), corrupting cursor state.
|
||||
if shouldDropCSI(data[i+2:j], data[j]) {
|
||||
// silently drop — vt10x would misinterpret
|
||||
} else if data[j] == 'm' && bytes.ContainsRune(data[i+2:j], ':') {
|
||||
result = append(result, 0x1b, '[')
|
||||
result = append(result, rewriteSGRParams(data[i+2:j])...)
|
||||
result = append(result, 'm')
|
||||
} else {
|
||||
result = append(result, data[start:j+1]...)
|
||||
}
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, data[i])
|
||||
i++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// extractCursorShape scans data for the last DECSCUSR sequence (\x1b[N SP q)
|
||||
// and returns the cursor shape value (0-6). Returns -1 if none found.
|
||||
// DECSCUSR: 0=default, 1=blinking block, 2=steady block, 3=blinking underline,
|
||||
// 4=steady underline, 5=blinking bar, 6=steady bar.
|
||||
func extractCursorShape(data []byte) int32 {
|
||||
shape := int32(-1)
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i] != 0x1b || i+1 >= len(data) || data[i+1] != '[' {
|
||||
continue
|
||||
}
|
||||
j := i + 2
|
||||
// Collect parameter + intermediate bytes (< 0x40)
|
||||
for j < len(data) && data[j] < 0x40 {
|
||||
j++
|
||||
}
|
||||
if j >= len(data) {
|
||||
break
|
||||
}
|
||||
params := data[i+2 : j]
|
||||
final := data[j]
|
||||
// DECSCUSR: CSI Ps SP q — params end with space (0x20), final is 'q'
|
||||
if final == 'q' && len(params) >= 2 && params[len(params)-1] == ' ' {
|
||||
// Parse the digit(s) before the space
|
||||
numPart := params[:len(params)-1]
|
||||
if len(numPart) == 1 && numPart[0] >= '0' && numPart[0] <= '6' {
|
||||
shape = int32(numPart[0] - '0')
|
||||
}
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return shape
|
||||
}
|
||||
|
||||
// extractCursorVisible scans data for the last cursor visibility toggle
|
||||
// (\x1b[?25h or \x1b[?25l). Returns 1 for show, 0 for hide, -1 if none found.
|
||||
func extractCursorVisible(data []byte) int32 {
|
||||
vis := int32(-1)
|
||||
for i := 0; i+5 < len(data); i++ {
|
||||
if data[i] != 0x1b || data[i+1] != '[' || data[i+2] != '?' ||
|
||||
data[i+3] != '2' || data[i+4] != '5' {
|
||||
continue
|
||||
}
|
||||
switch data[i+5] {
|
||||
case 'h':
|
||||
vis = 1
|
||||
i += 5
|
||||
case 'l':
|
||||
vis = 0
|
||||
i += 5
|
||||
}
|
||||
}
|
||||
return vis
|
||||
}
|
||||
|
||||
// lastCursorShowIndex returns the byte index of the last \x1b[?25h in data,
|
||||
// or -1 if not found. Used to split vt10x writes so we can capture cursor
|
||||
// position at the exact moment the child shows the cursor.
|
||||
func lastCursorShowIndex(data []byte) int {
|
||||
result := -1
|
||||
for i := 0; i+5 < len(data); i++ {
|
||||
if data[i] == 0x1b && data[i+1] == '[' && data[i+2] == '?' &&
|
||||
data[i+3] == '2' && data[i+4] == '5' && data[i+5] == 'h' {
|
||||
result = i
|
||||
i += 5
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// shouldDropCSI decides whether a CSI sequence should be stripped before it
|
||||
// reaches vt10x. Two categories are filtered:
|
||||
//
|
||||
// 1. Sequences with '>', '<', or '=' as the first parameter byte. These are
|
||||
// private-use extensions (Kitty keyboard protocol, DA2 responses, etc.)
|
||||
// that vt10x's CSI parser conflates with standard sequences.
|
||||
//
|
||||
// 2. '?'-prefixed sequences whose final byte is 'u'. The Kitty keyboard
|
||||
// query (\x1b[?u) would otherwise be dispatched as DECRC (cursor restore)
|
||||
// because vt10x's 'u' handler does not check the private flag.
|
||||
//
|
||||
// Regular '?'-prefixed sequences (\x1b[?1049h, \x1b[?25l, etc.) are NOT
|
||||
// filtered — vt10x handles those correctly via its priv flag.
|
||||
func shouldDropCSI(params []byte, finalByte byte) bool {
|
||||
if len(params) == 0 {
|
||||
return false
|
||||
}
|
||||
switch params[0] {
|
||||
case '>', '<', '=':
|
||||
return true
|
||||
case '?':
|
||||
return finalByte == 'u'
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user