PageSourceSearch

https://clawstation.ai/assets/whatsapp_terminal_controller-1d63509e.js

js clawstation.ai collected 2026-09-26 00:21:50 UTC 4,780 bytes, 160 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { createConsumer } from "@rails/actioncable"
3import { Terminal } from "@xterm/xterm"
4import { FitAddon } from "@xterm/addon-fit"
5
6export default class extends Controller {
7  static targets = ["terminal", "status", "successBanner"]
8  static values = { stationId: String, command: String, stationUrl: String }
9
10  // Patterns that indicate WhatsApp has successfully paired
11  static SUCCESS_PATTERNS = [
12    "successfully",
13    "connected",
14    "paired",
15    "logged in",
16    "session saved",
17    "ready"
18  ]
19
20  connect() {
21    this.connected = false
22    this.outputBuffer = ""
23    this.setupTerminal()
24    this.connectChannel()
25    this.boundResize = () => this.fitTerminal()
26    window.addEventListener("resize", this.boundResize)
27  }
28
29  disconnect() {
30    window.removeEventListener("resize", this.boundResize)
31    this.subscription?.unsubscribe()
32    this.term?.dispose()
33  }
34
35  setupTerminal() {
36    this.term = new Terminal({
37      cursorBlink: true,
38      fontSize: 14,
39      fontFamily: "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace",
40      theme: {
41        background: "#1e1e2e",
42        foreground: "#cdd6f4",
43        cursor: "#f5e0dc",
44        selectionBackground: "#585b70"
45      }
46    })
47
48    this.fitAddon = new FitAddon()
49    this.term.loadAddon(this.fitAddon)
50    this.term.open(this.terminalTarget)
51    this.fitTerminal()
52
53    this.term.onData(data => {
54      this.subscription?.send({ input: data })
55    })
56
57    this.term.onResize(({ rows, cols }) => {
58      this.subscription?.send({ resize: { rows, cols } })
59    })
60  }
61
62  fitTerminal() {
63    try {
64      this.fitAddon?.fit()
65      // WhatsApp pairs render a QR roughly 41-43 cols wide. xterm.js's
66      // FitAddon undercounts by 1 col when the viewport scrollbar takes
67      // pixel width — shaving the rightmost column off and wrapping the
68      // QR's last block to a new line, breaking scannability. Enforce a
69      // minimum cols (and rows) so the PTY is always wide enough.
70      const MIN_COLS = 80
71      const MIN_ROWS = 24
72      if (this.term && (this.term.cols < MIN_COLS || this.term.rows < MIN_ROWS)) {
73        this.term.resize(
74          Math.max(this.term.cols, MIN_COLS),
75          Math.max(this.term.rows, MIN_ROWS)
76        )
77      }
78    } catch (_e) {
79      // ignore fit errors during setup
80    }
81  }
82
83  connectChannel() {
84    if (this.hasStatusTarget) {
85      this.statusTarget.textContent = "Connecting..."
86    }
87
88    const consumer = createConsumer()
89    const controller = this
90
91    this.subscription = consumer.subscriptions.create(
92      { channel: "TerminalChannel", station_id: this.stationIdValue, command: this.commandValue },
93      {
94        connected() {
95          if (controller.hasStatusTarget) {
96            controller.statusTarget.textContent = "Connected"
97            setTimeout(() => { controller.statusTarget.style.display = "none" }, 1500)
98          }
99          controller.fitTerminal()
100          // Explicitly resync PTY size to the backend. setupTerminal() runs
101          // fit + resize BEFORE this.subscription exists, so the onResize
102          // signal that should size the backend PTY gets dropped. Without
103          // this, the server PTY stays at its default size and the QR wraps
104          // even though xterm itself is wide enough to show it intact.
105          if (controller.term) {
106            controller.subscription?.send({
107              resize: { rows: controller.term.rows, cols: controller.term.cols }
108            })
109          }
110        },
111
112        disconnected() {
113          controller.term?.write("\r\n\x1b[33m[Disconnected]\x1b[0m\r\n")
114        },
115
116        rejected() {
117          controller.term?.write("\r\n\x1b[31m[Connection rejected — station may not be running]\x1b[0m\r\n")
118        },
119
120        received(data) {
121          if (data.output) {
122            controller.term.write(data.output)
123            controller.checkForSuccess(data.output)
124          }
125        }
126      }
127    )
128  }
129
130  checkForSuccess(output) {
131    if (this.connected) return
132
133    // Buffer recent output to catch multi-chunk messages
134    this.outputBuffer += output
135    // Keep buffer manageable — only check the last 500 chars
136    if (this.outputBuffer.length > 500) {
137      this.outputBuffer = this.outputBuffer.slice(-500)
138    }
139
140    const lower = this.outputBuffer.toLowerCase()
141    const matched = this.constructor.SUCCESS_PATTERNS.some(p => lower.includes(p))
142    if (matched) {
143      this.showSuccess()
144    }
145  }
146
147  markConnected() {
148    // Manual fallback button action
149    this.showSuccess()
150  }
151
152  showSuccess() {
153    if (this.connected) return
154    this.connected = true
155
156    if (this.hasSuccessBannerTarget) {
157      this.successBannerTarget.style.display = "flex"
158    }
159  }
160}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.