Skip to content

feat(wsh): add wsh attach command to connect external terminals to Wave blocks#3255

Open
dfbb wants to merge 18 commits intowavetermdev:mainfrom
dfbb:feat/wsh-attach
Open

feat(wsh): add wsh attach command to connect external terminals to Wave blocks#3255
dfbb wants to merge 18 commits intowavetermdev:mainfrom
dfbb:feat/wsh-attach

Conversation

@dfbb
Copy link
Copy Markdown

@dfbb dfbb commented Apr 28, 2026

Summary

Adds wsh attach — a new subcommand that lets users attach an external terminal (iTerm, Terminal.app, etc.) to a running term block in Wave Terminal, providing bidirectional I/O.

  • Authentication: reads the Ed25519 JWT private key directly from Wave's SQLite database (read-only WAL mode) — no environment variables required
  • Output: subscribes to WPS blockfile events first, then reads the term file snapshot, then flushes buffered events after the snapshot cutoff to avoid missing bytes
  • Input: raw mode stdin forwarded via ControllerInputCommand; Ctrl+A D detaches without killing the shell
  • Resize: listens for SIGWINCH and sends updated TermSize to the block; restores the block's original term size on detach
  • Block selection: if no block ID is passed, lists all term blocks in the active workspace and auto-selects when only one exists; shows an interactive arrow-key UI when multiple exist
  • Exit detection: subscribes to Event_ControllerStatus and exits cleanly when the block's shell process finishes

Known Limitations

  • Inactive workspaces are not supported. Wave does not start shell controllers for blocks in inactive workspaces, so ControllerInputCommand returns no controller found. Only blocks in the currently active workspace accept input.
  • ANSI/escape sequence handling is incomplete. The attach loop forwards raw bytes; complex escape sequences (e.g. bracketed paste, mouse events) may not behave correctly in all external terminals.

Test Plan

  • wsh attach --help prints usage
  • With one term block open: wsh attach auto-attaches
  • With multiple term blocks: interactive selector appears, arrow keys and Enter work
  • Typing in the external terminal is reflected in the Wave block
  • Output from the Wave block appears in the external terminal in real time
  • Historical output is replayed on attach
  • Resizing the external terminal window updates the Wave block PTY size (stty size inside the block reflects the change)
  • Ctrl+A D detaches cleanly; the shell inside Wave keeps running
  • Ctrl+C is forwarded to the block (interrupts a running process)
  • Closing the block in Wave UI prints [block closed] and exits
  • wsh attach <blockid> skips the selector

dfbb added 18 commits April 27, 2026 17:50
Spec for a standalone CLI tool that attaches to an existing Wave Terminal
block for bidirectional terminal I/O from an external terminal emulator.
- Auth: replace nonexistent authkey file with direct SQLite read of
  jwtprivatekey from db_mainserver table (Wave uses WAL, allows ro reads)
- I/O: WaveFileReadStreamCommand is snapshot-only — switch to combined
  approach (initial snapshot + WPS "blockfile" event subscription) with
  ordering rules to avoid missed/duplicated bytes
- Term file size corrected to 2MB circular buffer (was 5MB)
- Note BlocksListCommand returns only IDs; selector needs separate
  workspace/tab name lookups
- Add WAVETERM_DATA_HOME env var support for data directory discovery
Move waveattach functionality into wsh as `wsh attach`. The standalone
cmd/waveattach entrypoint is removed; all logic stays in pkg/waveattach.
Localize selector UI strings to English.
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 28, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 28, 2026

Walkthrough

A new waveattach feature is added that enables attaching to terminal blocks in Wave Terminal. The implementation comprises a CLI command, authentication module that connects to Wave via socket and JWT, block selection logic with interactive UI, core attachment logic that handles terminal modes and I/O streaming, and output streaming combining snapshots with real-time events. The modules are integrated to provide end-to-end attachment functionality including detach sequence recognition (Ctrl-A+D), signal handling for terminal resize, and error handling at each step.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature: a new 'wsh attach' command for connecting external terminals to Wave blocks, which aligns with the primary changes across the codebase.
Description check ✅ Passed The description provides detailed context about the feature, implementation approach, known limitations, and test plan, all directly related to the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/waveattach/attach.go`:
- Around line 47-58: The Ctrl+A handling branch leaves p.gotPrefix true after
writing a literal Ctrl+A, causing the next byte to be misinterpreted as a detach
suffix; modify the case for ctrlA in the switch inside the attach logic so that
after successfully writing the literal ctrlA (the write in the ctrlA branch) you
also set p.gotPrefix = false (just like the default branch does) so the prefix
state is cleared and subsequent bytes are forwarded correctly.

In `@pkg/waveattach/output.go`:
- Around line 94-99: The cutoff timestamp is taken after readSnapshot completes,
which can cause later-written bytes to be buffered with an earlier timestamp and
dropped by buf.flush; move the cutoff capture to before draining the snapshot
stream so the replay cutoff reflects the actual start of draining. Specifically,
capture cutoff := time.Now() immediately before calling readSnapshot (and
likewise in the other occurrence around the second read/call at lines 129-135),
then pass that cutoff into buf.flush (or otherwise ensure buf.flush is called
with the earlier cutoff) so that buf.flush uses the correct replay cutoff for
the data processed by readSnapshot and subsequent io.Copy operations.

In `@pkg/waveattach/selector.go`:
- Around line 148-172: The input loop in the selector (reading from os.Stdin
into buf and switching on n/b) fails when ESC [ A/B arrives across multiple
Reads; add a tiny escape-state buffer/state machine in the same function
surrounding the loop (e.g., escBuf or escState) that accumulates bytes across
reads and matches the sequence ESC (27) then '[' then 'A'/'B' before acting on
Up/Down; when a complete sequence is recognized, update cur like the existing
cases, clear escBuf/state, and on any non-escape bytes fall back to the existing
single-byte handling (q, Ctrl-C, Enter -> entries[cur].BlockId + clear()/error
return). Ensure the state resets on invalid sequences and that normal
single-byte behavior (n==1 checks for 'q', 3, 13) still works when no escape
prefixed bytes are pending.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: afe47e64-3898-4c23-9900-f308285c5e13

📥 Commits

Reviewing files that changed from the base of the PR and between efd450f and 3cef289.

📒 Files selected for processing (8)
  • cmd/wsh/cmd/wshcmd-attach.go
  • pkg/waveattach/attach.go
  • pkg/waveattach/attach_test.go
  • pkg/waveattach/auth.go
  • pkg/waveattach/auth_test.go
  • pkg/waveattach/output.go
  • pkg/waveattach/output_test.go
  • pkg/waveattach/selector.go

Comment thread pkg/waveattach/attach.go
Comment on lines +47 to +58
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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset the prefix state after Ctrl+A Ctrl+A.

The doubled-Ctrl+A branch writes a literal Ctrl+A but leaves gotPrefix set. The next byte is still interpreted as a detach suffix, so sequences like Ctrl+A Ctrl+A d detach instead of forwarding ^Ad, and Ctrl+A Ctrl+A x emits an extra Ctrl+A.

💡 Suggested fix
 		switch c {
 		case 'd', 'D':
 			return true, nil
 		case ctrlA:
+			p.gotPrefix = false
 			if _, err := w.Write([]byte{ctrlA}); err != nil {
 				return false, err
 			}
 		default:
 			p.gotPrefix = false
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/waveattach/attach.go` around lines 47 - 58, The Ctrl+A handling branch
leaves p.gotPrefix true after writing a literal Ctrl+A, causing the next byte to
be misinterpreted as a detach suffix; modify the case for ctrlA in the switch
inside the attach logic so that after successfully writing the literal ctrlA
(the write in the ctrlA branch) you also set p.gotPrefix = false (just like the
default branch does) so the prefix state is cleared and subsequent bytes are
forwarded correctly.

Comment thread pkg/waveattach/output.go
Comment on lines +94 to +99
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Capture the replay cutoff before draining the snapshot.

cutoff := time.Now() is taken only after readSnapshot finishes copying the whole stream. Any output appended after the server snapshots term but before io.Copy completes is buffered with an earlier timestamp and then dropped by flush, so attach can lose terminal bytes under load. Cross-file evidence: pkg/wshrpc/wshserver/wshserver.go:1035-1075 reads the file before it starts the async stream writer.

💡 Suggested fix
 func StreamOutput(ctx context.Context, rpcClient *wshutil.WshRpc, blockId string, w io.Writer) error {
 	buf := makeEventBuffer()
 	blockRef := waveobj.MakeORef(waveobj.OType_Block, blockId).String()
@@
-	if err := readSnapshot(rpcClient, blockId, w); err != nil {
+	cutoff, err := readSnapshot(rpcClient, blockId, w)
+	if err != nil {
 		return fmt.Errorf("reading snapshot: %w", err)
 	}
-	cutoff := time.Now()
 	if err := buf.flush(cutoff, w); err != nil {
 		return err
 	}
@@
-func readSnapshot(rpcClient *wshutil.WshRpc, blockId string, w io.Writer) error {
+func readSnapshot(rpcClient *wshutil.WshRpc, blockId string, w io.Writer) (time.Time, error) {
 	broker := rpcClient.StreamBroker
 	if broker == nil {
-		return fmt.Errorf("stream broker not available")
+		return time.Time{}, 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)
+		return time.Time{}, fmt.Errorf("getting route id: %w", err)
 	}
 	if readerRouteId == "" {
-		return fmt.Errorf("no route to receive data")
+		return time.Time{}, fmt.Errorf("no route to receive data")
 	}
@@
 	_, err = wshclient.WaveFileReadStreamCommand(rpcClient, data, nil)
 	if err != nil {
-		return fmt.Errorf("starting stream read: %w", err)
+		return time.Time{}, fmt.Errorf("starting stream read: %w", err)
 	}
+	cutoff := time.Now()
 
 	_, err = io.Copy(w, reader)
 	if err != nil {
-		return fmt.Errorf("reading stream: %w", err)
+		return time.Time{}, fmt.Errorf("reading stream: %w", err)
 	}
-	return nil
+	return cutoff, nil
 }

Also applies to: 129-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/waveattach/output.go` around lines 94 - 99, The cutoff timestamp is taken
after readSnapshot completes, which can cause later-written bytes to be buffered
with an earlier timestamp and dropped by buf.flush; move the cutoff capture to
before draining the snapshot stream so the replay cutoff reflects the actual
start of draining. Specifically, capture cutoff := time.Now() immediately before
calling readSnapshot (and likewise in the other occurrence around the second
read/call at lines 129-135), then pass that cutoff into buf.flush (or otherwise
ensure buf.flush is called with the earlier cutoff) so that buf.flush uses the
correct replay cutoff for the data processed by readSnapshot and subsequent
io.Copy operations.

Comment on lines +148 to +172
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++
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle arrow-key escape sequences across reads.

This parser only recognizes Up/Down when the entire ESC [ A/B sequence lands in one Read. In raw mode those bytes can arrive split, so selector navigation becomes unreliable on some terminals. A tiny escape-sequence buffer/state machine here would make the UI deterministic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/waveattach/selector.go` around lines 148 - 172, The input loop in the
selector (reading from os.Stdin into buf and switching on n/b) fails when ESC [
A/B arrives across multiple Reads; add a tiny escape-state buffer/state machine
in the same function surrounding the loop (e.g., escBuf or escState) that
accumulates bytes across reads and matches the sequence ESC (27) then '[' then
'A'/'B' before acting on Up/Down; when a complete sequence is recognized, update
cur like the existing cases, clear escBuf/state, and on any non-escape bytes
fall back to the existing single-byte handling (q, Ctrl-C, Enter ->
entries[cur].BlockId + clear()/error return). Ensure the state resets on invalid
sequences and that normal single-byte behavior (n==1 checks for 'q', 3, 13)
still works when no escape prefixed bytes are pending.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants