diff --git a/cmd/wsh/cmd/wshcmd-attach.go b/cmd/wsh/cmd/wshcmd-attach.go new file mode 100644 index 0000000000..afc12342a7 --- /dev/null +++ b/cmd/wsh/cmd/wshcmd-attach.go @@ -0,0 +1,41 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" + "github.com/wavetermdev/waveterm/pkg/waveattach" +) + +var attachCmd = &cobra.Command{ + Use: "attach [blockid]", + Short: "attach to a Wave Terminal block from an external terminal", + Long: "Attach to a running term block in Wave Terminal. Press Ctrl+A D to detach.", + Args: cobra.MaximumNArgs(1), + RunE: attachRun, + DisableFlagsInUseLine: true, +} + +func init() { + rootCmd.AddCommand(attachCmd) +} + +func attachRun(cmd *cobra.Command, args []string) error { + rpcClient, _, err := waveattach.Connect() + if err != nil { + return err + } + + var blockId string + if len(args) == 1 { + blockId = args[0] + } else { + blockId, err = waveattach.SelectBlock(rpcClient) + if err != nil { + return err + } + } + + return waveattach.Attach(rpcClient, blockId) +} diff --git a/pkg/waveattach/attach.go b/pkg/waveattach/attach.go new file mode 100644 index 0000000000..6adc8438d0 --- /dev/null +++ b/pkg/waveattach/attach.go @@ -0,0 +1,209 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "os/signal" + "syscall" + + "github.com/wavetermdev/waveterm/pkg/blockcontroller" + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "golang.org/x/term" +) + +const ctrlA = 0x01 + +type prefixKey struct { + gotPrefix bool +} + +func newPrefixKey() *prefixKey { return &prefixKey{} } + +func (p *prefixKey) feed(b []byte, w io.Writer) (detach bool, err error) { + for _, c := range b { + if !p.gotPrefix { + if c == ctrlA { + p.gotPrefix = true + continue + } + if _, err := w.Write([]byte{c}); err != nil { + return false, err + } + continue + } + switch c { + case 'd', 'D': + return true, nil + case ctrlA: + if _, err := w.Write([]byte{ctrlA}); err != nil { + return false, err + } + default: + p.gotPrefix = false + if _, err := w.Write([]byte{ctrlA, c}); err != nil { + return false, err + } + } + } + return false, nil +} + +var ErrDetached = errors.New("detached") +var ErrBlockClosed = errors.New("block closed") + +func Attach(rpcClient *wshutil.WshRpc, blockId string) error { + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + return fmt.Errorf("stdin is not a terminal") + } + oldState, err := term.MakeRaw(fd) + if err != nil { + return fmt.Errorf("entering raw mode: %w", err) + } + defer term.Restore(fd, oldState) + + origTermSize := getBlockTermSize(rpcClient, blockId) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + exitCh := make(chan error, 3) + + winchCh := make(chan os.Signal, 1) + signal.Notify(winchCh, syscall.SIGWINCH) + defer signal.Stop(winchCh) + + sendTermSize := func() { + w, h, err := term.GetSize(fd) + if err != nil { + return + } + data := wshrpc.CommandBlockInputData{ + BlockId: blockId, + TermSize: &waveobj.TermSize{Rows: h, Cols: w}, + } + _ = wshclient.ControllerInputCommand(rpcClient, data, nil) + } + sendTermSize() + + go func() { + for { + select { + case <-winchCh: + sendTermSize() + case <-ctx.Done(): + return + } + } + }() + + blockRef := waveobj.MakeORef(waveobj.OType_Block, blockId).String() + rpcClient.EventListener.On(wps.Event_ControllerStatus, func(ev *wps.WaveEvent) { + if !ev.HasScope(blockRef) { + return + } + var status blockcontroller.BlockControllerRuntimeStatus + if err := utilfn.ReUnmarshal(&status, ev.Data); err != nil { + return + } + if status.ShellProcStatus == blockcontroller.Status_Done { + exitCh <- ErrBlockClosed + } + }) + subReq := wps.SubscriptionRequest{ + Event: wps.Event_ControllerStatus, + Scopes: []string{blockRef}, + } + if err := wshclient.EventSubCommand(rpcClient, subReq, nil); err != nil { + return fmt.Errorf("subscribing to controllerstatus events: %w", err) + } + + go func() { + exitCh <- StreamOutput(ctx, rpcClient, blockId, os.Stdout) + }() + + go func() { + exitCh <- inputLoop(ctx, rpcClient, blockId) + }() + + exitErr := <-exitCh + cancel() + if origTermSize != nil { + restoreData := wshrpc.CommandBlockInputData{ + BlockId: blockId, + TermSize: origTermSize, + } + _ = wshclient.ControllerInputCommand(rpcClient, restoreData, &wshrpc.RpcOpts{Timeout: 3000}) + } + // ensure cursor is at column 0 before printing exit message + fmt.Fprintf(os.Stdout, "\r\n") + switch { + case errors.Is(exitErr, ErrDetached): + fmt.Fprintf(os.Stderr, "\r\n[detached]\r\n") + return nil + case errors.Is(exitErr, ErrBlockClosed): + fmt.Fprintf(os.Stderr, "\r\n[block closed]\r\n") + return nil + case exitErr != nil: + fmt.Fprintf(os.Stderr, "\r\n[error] %v\r\n", exitErr) + return exitErr + } + return nil +} + +func inputLoop(ctx context.Context, rpcClient *wshutil.WshRpc, blockId string) error { + pk := newPrefixKey() + buf := make([]byte, 4096) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + return err + } + var forward bytes.Buffer + detach, err := pk.feed(buf[:n], &forward) + if err != nil { + return err + } + if forward.Len() > 0 { + data := wshrpc.CommandBlockInputData{ + BlockId: blockId, + InputData64: base64.StdEncoding.EncodeToString(forward.Bytes()), + } + // ignore transient RPC errors (e.g. timeout under rapid input) to keep the attach alive + wshclient.ControllerInputCommand(rpcClient, data, &wshrpc.RpcOpts{Timeout: 2000}) + } + if detach { + return ErrDetached + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } +} + +func getBlockTermSize(rpcClient *wshutil.WshRpc, blockId string) *waveobj.TermSize { + info, err := wshclient.BlockInfoCommand(rpcClient, blockId, &wshrpc.RpcOpts{Timeout: 3000}) + if err != nil || info == nil || info.Block == nil { + return nil + } + rtOpts := info.Block.RuntimeOpts + if rtOpts == nil || (rtOpts.TermSize.Rows == 0 && rtOpts.TermSize.Cols == 0) { + return nil + } + return &waveobj.TermSize{Rows: rtOpts.TermSize.Rows, Cols: rtOpts.TermSize.Cols} +} diff --git a/pkg/waveattach/attach_test.go b/pkg/waveattach/attach_test.go new file mode 100644 index 0000000000..710f8e8016 --- /dev/null +++ b/pkg/waveattach/attach_test.go @@ -0,0 +1,81 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "bytes" + "testing" +) + +func TestPrefixKeyMachine_PlainBytesPassThrough(t *testing.T) { + m := newPrefixKey() + var out bytes.Buffer + det, err := m.feed([]byte("hello"), &out) + if err != nil || det { + t.Fatalf("unexpected: detach=%v err=%v", det, err) + } + if out.String() != "hello" { + t.Errorf("want 'hello', got %q", out.String()) + } +} + +func TestPrefixKeyMachine_DetachOnCtrlAD(t *testing.T) { + m := newPrefixKey() + var out bytes.Buffer + det, _ := m.feed([]byte{0x01, 'd'}, &out) + if !det { + t.Fatal("expected detach") + } + if out.Len() != 0 { + t.Errorf("expected nothing forwarded, got %q", out.String()) + } +} + +func TestPrefixKeyMachine_DetachOnCtrlACapitalD(t *testing.T) { + m := newPrefixKey() + var out bytes.Buffer + det, _ := m.feed([]byte{0x01, 'D'}, &out) + if !det { + t.Fatal("expected detach") + } +} + +func TestPrefixKeyMachine_LiteralCtrlAByDoubling(t *testing.T) { + m := newPrefixKey() + var out bytes.Buffer + det, _ := m.feed([]byte{0x01, 0x01}, &out) + if det { + t.Fatal("did not expect detach") + } + if !bytes.Equal(out.Bytes(), []byte{0x01}) { + t.Errorf("want 0x01, got %v", out.Bytes()) + } +} + +func TestPrefixKeyMachine_PrefixThenOtherKey(t *testing.T) { + m := newPrefixKey() + var out bytes.Buffer + det, _ := m.feed([]byte{0x01, 'x'}, &out) + if det { + t.Fatal("did not expect detach") + } + if !bytes.Equal(out.Bytes(), []byte{0x01, 'x'}) { + t.Errorf("want [0x01 'x'], got %v", out.Bytes()) + } +} + +func TestPrefixKeyMachine_PrefixSplitAcrossReads(t *testing.T) { + m := newPrefixKey() + var out bytes.Buffer + if det, _ := m.feed([]byte{0x01}, &out); det { + t.Fatal("did not expect detach yet") + } + if out.Len() != 0 { + t.Errorf("expected buffered, got %q", out.String()) + } + det, _ := m.feed([]byte{'d'}, &out) + if !det { + t.Fatal("expected detach on second feed") + } +} diff --git a/pkg/waveattach/auth.go b/pkg/waveattach/auth.go new file mode 100644 index 0000000000..f55b556fa2 --- /dev/null +++ b/pkg/waveattach/auth.go @@ -0,0 +1,143 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "github.com/wavetermdev/waveterm/pkg/wavejwt" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +const ( + dbSubdir = "db" + dbFileName = "waveterm.db" + socketFileName = "wave.sock" +) + +func ResolveDataDir() (string, error) { + if v := os.Getenv("WAVETERM_DATA_HOME"); v != "" { + return v, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot resolve home dir: %w", err) + } + var candidates []string + switch runtime.GOOS { + case "darwin": + candidates = []string{ + filepath.Join(home, "Library", "Application Support", "waveterm"), + filepath.Join(home, "Library", "Application Support", "waveterm-dev"), + filepath.Join(home, ".waveterm"), + filepath.Join(home, ".waveterm-dev"), + } + case "linux": + xdgData := os.Getenv("XDG_DATA_HOME") + if xdgData == "" { + xdgData = filepath.Join(home, ".local", "share") + } + candidates = []string{ + filepath.Join(xdgData, "waveterm"), + filepath.Join(xdgData, "waveterm-dev"), + filepath.Join(home, ".waveterm"), + filepath.Join(home, ".waveterm-dev"), + } + default: + candidates = []string{ + filepath.Join(home, ".waveterm"), + filepath.Join(home, ".waveterm-dev"), + } + } + for _, candidate := range candidates { + if st, err := os.Stat(candidate); err == nil && st.IsDir() { + return candidate, nil + } + } + return "", fmt.Errorf("Wave data directory not found. Is Wave running? (set $WAVETERM_DATA_HOME to override)") +} + +func loadJwtPrivateKey(dataDir string) (ed25519.PrivateKey, error) { + dbPath := filepath.Join(dataDir, dbSubdir, dbFileName) + if _, err := os.Stat(dbPath); err != nil { + return nil, fmt.Errorf("Wave database not found at %s: %w", dbPath, err) + } + dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL&_busy_timeout=5000", dbPath) + db, err := sqlx.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("opening wave db: %w", err) + } + defer db.Close() + + var rawJSON string + if err := db.Get(&rawJSON, "SELECT data FROM db_mainserver LIMIT 1"); err != nil { + return nil, fmt.Errorf("querying db_mainserver (Wave schema may have changed): %w", err) + } + var ms struct { + JwtPrivateKey string `json:"jwtprivatekey"` + } + if err := json.Unmarshal([]byte(rawJSON), &ms); err != nil { + return nil, fmt.Errorf("parsing mainserver JSON: %w", err) + } + if ms.JwtPrivateKey == "" { + return nil, fmt.Errorf("jwtprivatekey is empty in db_mainserver") + } + keyBytes, err := base64.StdEncoding.DecodeString(ms.JwtPrivateKey) + if err != nil { + return nil, fmt.Errorf("base64 decoding jwt private key: %w", err) + } + if len(keyBytes) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("jwt private key has wrong length: got %d, want %d", len(keyBytes), ed25519.PrivateKeySize) + } + return ed25519.PrivateKey(keyBytes), nil +} + +func Connect() (*wshutil.WshRpc, string, error) { + dataDir, err := ResolveDataDir() + if err != nil { + return nil, "", err + } + sockPath := filepath.Join(dataDir, socketFileName) + if _, err := os.Stat(sockPath); err != nil { + return nil, "", fmt.Errorf("Wave socket not found at %s: %w", sockPath, err) + } + + privKey, err := loadJwtPrivateKey(dataDir) + if err != nil { + return nil, "", err + } + if err := wavejwt.SetPrivateKey([]byte(privKey)); err != nil { + return nil, "", fmt.Errorf("setting jwt private key: %w", err) + } + + routeId := "waveattach-" + uuid.NewString() + rpcCtx := wshrpc.RpcContext{ + SockName: sockPath, + RouteId: routeId, + } + jwtToken, err := wshutil.MakeClientJWTToken(rpcCtx) + if err != nil { + return nil, "", fmt.Errorf("creating jwt: %w", err) + } + rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockPath, nil, "waveattach") + if err != nil { + return nil, "", fmt.Errorf("connecting to %s: %w", sockPath, err) + } + authRtn, err := wshclient.AuthenticateCommand(rpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + return nil, "", fmt.Errorf("authenticating: %w", err) + } + return rpcClient, authRtn.RouteId, nil +} diff --git a/pkg/waveattach/auth_test.go b/pkg/waveattach/auth_test.go new file mode 100644 index 0000000000..d0fcdf00de --- /dev/null +++ b/pkg/waveattach/auth_test.go @@ -0,0 +1,65 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveDataDir_EnvOverride(t *testing.T) { + tmp := t.TempDir() + t.Setenv("WAVETERM_DATA_HOME", tmp) + got, err := ResolveDataDir() + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != tmp { + t.Errorf("want %q, got %q", tmp, got) + } +} + +func TestResolveDataDir_FallbackProd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("WAVETERM_DATA_HOME", "") + prod := filepath.Join(home, ".waveterm") + if err := os.MkdirAll(prod, 0700); err != nil { + t.Fatal(err) + } + got, err := ResolveDataDir() + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != prod { + t.Errorf("want %q, got %q", prod, got) + } +} + +func TestResolveDataDir_FallbackDev(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("WAVETERM_DATA_HOME", "") + dev := filepath.Join(home, ".waveterm-dev") + if err := os.MkdirAll(dev, 0700); err != nil { + t.Fatal(err) + } + got, err := ResolveDataDir() + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != dev { + t.Errorf("want %q, got %q", dev, got) + } +} + +func TestResolveDataDir_NoneFound(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("WAVETERM_DATA_HOME", "") + if _, err := ResolveDataDir(); err == nil { + t.Fatal("expected error when no data dir exists") + } +} diff --git a/pkg/waveattach/output.go b/pkg/waveattach/output.go new file mode 100644 index 0000000000..836f119786 --- /dev/null +++ b/pkg/waveattach/output.go @@ -0,0 +1,139 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "sync" + "time" + + "github.com/wavetermdev/waveterm/pkg/util/utilfn" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" +) + +type pendingEvent struct { + at time.Time + data []byte +} + +type eventBuffer struct { + mu sync.Mutex + pending []pendingEvent + flushed bool +} + +func makeEventBuffer() *eventBuffer { + return &eventBuffer{} +} + +func (b *eventBuffer) flush(cutoff time.Time, w io.Writer) error { + b.mu.Lock() + defer b.mu.Unlock() + for _, ev := range b.pending { + if ev.at.After(cutoff) { + if _, err := w.Write(ev.data); err != nil { + return err + } + } + } + b.pending = nil + b.flushed = true + return nil +} + +func (b *eventBuffer) write(at time.Time, data []byte, w io.Writer) error { + b.mu.Lock() + defer b.mu.Unlock() + if !b.flushed { + b.pending = append(b.pending, pendingEvent{at: at, data: data}) + return nil + } + _, err := w.Write(data) + return err +} + +func StreamOutput(ctx context.Context, rpcClient *wshutil.WshRpc, blockId string, w io.Writer) error { + buf := makeEventBuffer() + blockRef := waveobj.MakeORef(waveobj.OType_Block, blockId).String() + + rpcClient.EventListener.On(wps.Event_BlockFile, func(ev *wps.WaveEvent) { + var fed wps.WSFileEventData + if err := utilfn.ReUnmarshal(&fed, ev.Data); err != nil { + return + } + if fed.ZoneId != blockId || fed.FileName != wavebase.BlockFile_Term { + return + } + if fed.FileOp != wps.FileOp_Append { + return + } + data, err := base64.StdEncoding.DecodeString(fed.Data64) + if err != nil { + return + } + _ = buf.write(time.Now(), data, w) + }) + + subReq := wps.SubscriptionRequest{ + Event: wps.Event_BlockFile, + Scopes: []string{blockRef}, + } + if err := wshclient.EventSubCommand(rpcClient, subReq, nil); err != nil { + return fmt.Errorf("subscribing to blockfile events: %w", err) + } + + if err := readSnapshot(rpcClient, blockId, w); err != nil { + return fmt.Errorf("reading snapshot: %w", err) + } + cutoff := time.Now() + if err := buf.flush(cutoff, w); err != nil { + return err + } + + <-ctx.Done() + return nil +} + +func readSnapshot(rpcClient *wshutil.WshRpc, blockId string, w io.Writer) error { + broker := rpcClient.StreamBroker + if broker == nil { + return fmt.Errorf("stream broker not available") + } + + readerRouteId, err := wshclient.ControlGetRouteIdCommand(rpcClient, &wshrpc.RpcOpts{Route: wshutil.ControlRoute}) + if err != nil { + return fmt.Errorf("getting route id: %w", err) + } + if readerRouteId == "" { + return fmt.Errorf("no route to receive data") + } + + reader, streamMeta := broker.CreateStreamReader(readerRouteId, "", 64*1024) + defer reader.Close() + + data := wshrpc.CommandWaveFileReadStreamData{ + ZoneId: blockId, + Name: wavebase.BlockFile_Term, + StreamMeta: *streamMeta, + } + + _, err = wshclient.WaveFileReadStreamCommand(rpcClient, data, nil) + if err != nil { + return fmt.Errorf("starting stream read: %w", err) + } + + _, err = io.Copy(w, reader) + if err != nil { + return fmt.Errorf("reading stream: %w", err) + } + return nil +} diff --git a/pkg/waveattach/output_test.go b/pkg/waveattach/output_test.go new file mode 100644 index 0000000000..a90016bb3f --- /dev/null +++ b/pkg/waveattach/output_test.go @@ -0,0 +1,37 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "bytes" + "testing" + "time" +) + +func TestEventBuffer_ReplayAfterCutoff(t *testing.T) { + buf := makeEventBuffer() + t0 := time.Now() + _ = buf.write(t0, []byte("A"), nil) + _ = buf.write(t0.Add(10*time.Millisecond), []byte("B"), nil) + cutoff := t0.Add(20 * time.Millisecond) + _ = buf.write(cutoff.Add(time.Millisecond), []byte("C"), nil) + + var out bytes.Buffer + buf.flush(cutoff, &out) + if got := out.String(); got != "C" { + t.Errorf("want %q, got %q", "C", got) + } +} + +func TestEventBuffer_StreamModeAfterFlush(t *testing.T) { + buf := makeEventBuffer() + cutoff := time.Now() + buf.flush(cutoff, &bytes.Buffer{}) + + var out bytes.Buffer + buf.write(cutoff.Add(time.Second), []byte("hello"), &out) + if got := out.String(); got != "hello" { + t.Errorf("want %q, got %q", "hello", got) + } +} diff --git a/pkg/waveattach/selector.go b/pkg/waveattach/selector.go new file mode 100644 index 0000000000..d449e2a9e3 --- /dev/null +++ b/pkg/waveattach/selector.go @@ -0,0 +1,177 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package waveattach + +import ( + "fmt" + "os" + "strings" + + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "golang.org/x/term" +) + +type blockEntry struct { + BlockId string + Workspace string + Tab string + Cwd string +} + +func ListTermBlocks(rpcClient *wshutil.WshRpc) ([]blockEntry, error) { + allBlocks, err := wshclient.BlocksListCommand(rpcClient, wshrpc.BlocksListRequest{}, nil) + if err != nil { + return nil, fmt.Errorf("listing blocks: %w", err) + } + + wsCache := make(map[string]string) + if wsList, err := wshclient.WorkspaceListCommand(rpcClient, nil); err == nil { + for _, ws := range wsList { + if ws.WorkspaceData != nil { + wsCache[ws.WorkspaceData.OID] = ws.WorkspaceData.Name + } + } + } + tabCache := make(map[string]string) + + var entries []blockEntry + for _, blk := range allBlocks { + view := blk.Meta.GetString(waveobj.MetaKey_View, "") + if view != "term" { + continue + } + + wsName := wsCache[blk.WorkspaceId] + if wsName == "" { + wsName = blk.WorkspaceId + if len(wsName) > 8 { + wsName = wsName[:8] + } + } + + tabName, ok := tabCache[blk.TabId] + if !ok { + tab, err := wshclient.GetTabCommand(rpcClient, blk.TabId, nil) + if err == nil && tab != nil { + tabName = tab.Name + } + if tabName == "" { + short := blk.TabId + if len(short) > 8 { + short = short[:8] + } + tabName = short + } + tabCache[blk.TabId] = tabName + } + + cwd := blk.Meta.GetString(waveobj.MetaKey_CmdCwd, "") + + entries = append(entries, blockEntry{ + BlockId: blk.BlockId, + Workspace: wsName, + Tab: tabName, + Cwd: cwd, + }) + } + return entries, nil +} + +func SelectBlock(rpcClient *wshutil.WshRpc) (string, error) { + entries, err := ListTermBlocks(rpcClient) + if err != nil { + return "", err + } + if len(entries) == 0 { + return "", fmt.Errorf("no running term blocks found") + } + if len(entries) == 1 { + return entries[0].BlockId, nil + } + return runInteractiveSelector(entries) +} + +func runInteractiveSelector(entries []blockEntry) (string, error) { + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + return "", fmt.Errorf("multiple blocks found but stdin is not a terminal — pass blockid explicitly") + } + oldState, err := term.MakeRaw(fd) + if err != nil { + return "", fmt.Errorf("entering raw mode: %w", err) + } + defer term.Restore(fd, oldState) + + cur := 0 + render := func() { + var sb strings.Builder + sb.WriteString("\r\nSelect a block to attach to:\r\n\r\n") + for i, e := range entries { + prefix := " " + if i == cur { + prefix = "\033[7m▶" + } + cwd := e.Cwd + if cwd == "" { + cwd = "—" + } + line := fmt.Sprintf("%s [%d] term │ workspace: %-16s │ tab: %-12s │ cwd: %s", + prefix, i+1, e.Workspace, e.Tab, cwd) + if i == cur { + line += "\033[0m" + } + sb.WriteString(line + "\r\n") + } + sb.WriteString("\r\n↑/↓ move Enter select q quit │ block: ") + sb.WriteString(entries[cur].BlockId) + sb.WriteString("\r\n") + + totalLines := len(entries) + 5 + fmt.Fprint(os.Stderr, sb.String()) + fmt.Fprintf(os.Stderr, "\033[%dA", totalLines) + } + + clear := func() { + totalLines := len(entries) + 5 + for i := 0; i < totalLines; i++ { + fmt.Fprint(os.Stderr, "\033[2K\r\n") + } + fmt.Fprintf(os.Stderr, "\033[%dA", totalLines) + } + + render() + + buf := make([]byte, 4) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + return "", err + } + b := buf[:n] + + switch { + case n == 1 && (b[0] == 'q' || b[0] == 3): + clear() + return "", fmt.Errorf("cancelled") + case n == 1 && b[0] == 13: + selected := entries[cur].BlockId + clear() + return selected, nil + case n == 3 && b[0] == 27 && b[1] == '[' && b[2] == 'A': + if cur > 0 { + cur-- + } + case n == 3 && b[0] == 27 && b[1] == '[' && b[2] == 'B': + if cur < len(entries)-1 { + cur++ + } + } + + clear() + render() + } +}