plazbot-cli 0.3.3 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,25 +12,26 @@ import '../slash/handlers.js'; // side effect: registra slashes
12
12
  import { slashRegistry } from '../slash/registry.js';
13
13
  import { streamStudio } from '../api/sseClient.js';
14
14
  import { StudioHttpError } from '../api/types.js';
15
- import { startWhatsappPolling } from '../whatsapp/polling.js';
16
- import { workspaceOptsFromStream } from '../whatsapp/api.js';
15
+ import { startConnectPolling } from '../connect/polling.js';
16
+ import { workspaceOptsFromStream } from '../connect/api.js';
17
+ import { channelFromToolResultType } from '../connect/types.js';
17
18
  export function App({ version, stream, initialAgentId, dev, supportMode }) {
18
19
  const { exit } = useApp();
19
20
  const messages = useStudioStore((s) => s.messages);
20
21
  const usage = useStudioStore((s) => s.usage);
21
22
  const streaming = useStudioStore((s) => s.streaming);
22
23
  const agentId = useStudioStore((s) => s.agentId);
23
- // Active WhatsApp polling handle (cancelled on Esc / unmount / new stream).
24
- const waPollingRef = useRef(null);
24
+ // Active channel-connect polling handle (cancelled on Esc / unmount / new stream).
25
+ const connectPollingRef = useRef(null);
25
26
  const streamingRef = useRef(streaming);
26
27
  useEffect(() => { streamingRef.current = streaming; }, [streaming]);
27
28
  // Ref so the polling onConnected callback can trigger sendToBackend from the
28
29
  // most recent closure without resubscribing every render.
29
30
  const sendBackendRef = useRef(async () => { });
30
- const stopWaPolling = useCallback(() => {
31
- if (waPollingRef.current) {
32
- waPollingRef.current.cancel();
33
- waPollingRef.current = null;
31
+ const stopConnectPolling = useCallback(() => {
32
+ if (connectPollingRef.current) {
33
+ connectPollingRef.current.cancel();
34
+ connectPollingRef.current = null;
34
35
  }
35
36
  }, []);
36
37
  // Cargar agente inicial si se pasó por flag.
@@ -42,22 +43,22 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
42
43
  }, []);
43
44
  // Cleanup on unmount.
44
45
  useEffect(() => {
45
- return () => { stopWaPolling(); };
46
- }, [stopWaPolling]);
46
+ return () => { stopConnectPolling(); };
47
+ }, [stopConnectPolling]);
47
48
  // Atajos de teclado globales.
48
49
  useInput((input, key) => {
49
50
  if (key.escape) {
50
- const wa = useStudioStore.getState().waConnect;
51
- if (wa && (wa.status === 'waiting' || wa.status === 'activating')) {
52
- stopWaPolling();
53
- useStudioStore.getState().setWaStatus('cancelled');
51
+ const cc = useStudioStore.getState().connectCard;
52
+ if (cc && (cc.status === 'waiting' || cc.status === 'activating')) {
53
+ stopConnectPolling();
54
+ useStudioStore.getState().setConnectStatus('cancelled');
54
55
  return;
55
56
  }
56
57
  useStudioStore.getState().abortStream();
57
58
  return;
58
59
  }
59
60
  if (key.ctrl && input === 'c') {
60
- stopWaPolling();
61
+ stopConnectPolling();
61
62
  useStudioStore.getState().abortStream();
62
63
  exit();
63
64
  return;
@@ -99,37 +100,43 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
99
100
  s.setAgentId(idCandidate);
100
101
  }
101
102
  }
102
- // WhatsApp connect tool: spin up the card + polling.
103
- if (tr?.type === 'whatsapp_link' && data) {
103
+ // Channel connect tool (whatsapp / instagram / messenger): spin up the
104
+ // card + polling. The chunk discriminator (`whatsapp_link`,
105
+ // `instagram_link`, `messenger_link`) tells us which channel.
106
+ const channel = channelFromToolResultType(tr?.type);
107
+ if (channel && data) {
104
108
  const linkData = data;
105
109
  // No link to show (e.g. alreadyConnected without a new url) → skip card.
106
110
  if (linkData.shortUrl) {
107
111
  // Cancel any previous active polling before starting a new one.
108
- stopWaPolling();
109
- s.startWaConnect(linkData, assistantId);
110
- waPollingRef.current = startWhatsappPolling(workspaceOptsFromStream(stream), linkData, {
112
+ stopConnectPolling();
113
+ s.startConnectCard(channel, linkData, assistantId);
114
+ connectPollingRef.current = startConnectPolling(workspaceOptsFromStream(stream), channel, linkData, {
111
115
  onAttempt(attempt) {
112
- useStudioStore.getState().updateWaConnect({ attempt });
116
+ useStudioStore.getState().updateConnectCard({ attempt });
113
117
  },
114
118
  onActivating() {
115
- useStudioStore.getState().setWaStatus('activating');
119
+ useStudioStore.getState().setConnectStatus('activating');
116
120
  },
117
- onConnected(number) {
118
- useStudioStore.getState().setWaStatus('connected', { connectedNumber: number });
119
- waPollingRef.current = null;
121
+ onConnected(label) {
122
+ useStudioStore.getState().setConnectStatus('connected', { connectedLabel: label });
123
+ connectPollingRef.current = null;
120
124
  if (!streamingRef.current) {
121
- const followUp = `My WhatsApp is now connected. Number: ${number || '(no number)'}. ` +
125
+ const channelName = channel === 'whatsapp' ? 'WhatsApp'
126
+ : channel === 'instagram' ? 'Instagram'
127
+ : 'Messenger';
128
+ const followUp = `My ${channelName} is now connected. ${label ? `Label: ${label}.` : ''} ` +
122
129
  `Continue with the agent setup.`;
123
130
  void sendBackendRef.current(followUp);
124
131
  }
125
132
  },
126
133
  onTimeout() {
127
- useStudioStore.getState().setWaStatus('timeout');
128
- waPollingRef.current = null;
134
+ useStudioStore.getState().setConnectStatus('timeout');
135
+ connectPollingRef.current = null;
129
136
  },
130
137
  onError(message) {
131
- useStudioStore.getState().setWaStatus('error', { errorMessage: message });
132
- waPollingRef.current = null;
138
+ useStudioStore.getState().setConnectStatus('error', { errorMessage: message });
139
+ connectPollingRef.current = null;
133
140
  },
134
141
  });
135
142
  }
@@ -169,7 +176,7 @@ export function App({ version, stream, initialAgentId, dev, supportMode }) {
169
176
  useStudioStore.getState().compactInto(assistantId);
170
177
  }
171
178
  }
172
- }, [stream, dev, stopWaPolling]);
179
+ }, [stream, dev, stopConnectPolling]);
173
180
  useEffect(() => { sendBackendRef.current = sendToBackend; }, [sendToBackend]);
174
181
  const handleSubmit = useCallback(async (raw) => {
175
182
  const parsed = parseSlash(raw);
@@ -4,35 +4,38 @@ import { Box, Static } from 'ink';
4
4
  import { useStudioStore } from '../state/store.js';
5
5
  import { Message } from './Message.js';
6
6
  import { ToolCall } from './ToolCall.js';
7
- import { WhatsappConnectCard } from './WhatsappConnectCard.js';
7
+ import { ConnectCard } from './ConnectCard.js';
8
8
  /**
9
- * Renders the conversation log.
9
+ * Renderiza el log de la conversacion.
10
10
  *
11
- * Stable items (finished assistant/user messages + resolved tool steps that
12
- * are not anchored to an active WhatsApp card) are rendered inside <Static>
13
- * so Ink writes them to scrollback once and never re-renders them on resize.
14
- * This is what fixes the "Header echoed multiple times" effect you see when
15
- * dragging the terminal window while the dynamic frame is taller than the
16
- * viewport.
11
+ * Items estables (mensajes finalizados + tool steps resueltos que no estan
12
+ * anclados a una tarjeta de conexion activa) van dentro de <Static> para que
13
+ * Ink los escriba al scrollback una sola vez y no los re-renderice en resize.
17
14
  *
18
- * The dynamic block at the bottom contains the streaming message, any steps
19
- * still in `running`, and the WhatsApp connect card while it is active.
15
+ * El bloque dinamico al final contiene el mensaje en streaming, los steps que
16
+ * siguen en `running`, y la tarjeta de conexion (whatsapp/instagram/messenger)
17
+ * mientras esta activa.
20
18
  */
21
19
  export function ChatLog() {
22
20
  const messages = useStudioStore((s) => s.messages);
23
21
  const steps = useStudioStore((s) => s.steps);
24
- const waConnect = useStudioStore((s) => s.waConnect);
25
- // Group steps by their anchor message for quick lookup.
22
+ const connectCard = useStudioStore((s) => s.connectCard);
26
23
  const stepsByMsg = new Map();
27
24
  for (const step of steps) {
28
25
  const arr = stepsByMsg.get(step.afterMessageId) ?? [];
29
26
  arr.push(step);
30
27
  stepsByMsg.set(step.afterMessageId, arr);
31
28
  }
32
- // Determine which message ids must stay in the dynamic frame:
33
- // - the last message if it's still streaming
34
- // - any message whose tool steps are still running
35
- // - the message anchored to an active WhatsApp connect card
29
+ // La tarjeta solo permanece en el dynamic frame mientras esta "viva"
30
+ // (polling activo o activando). En estados terminales
31
+ // (connected/cancelled/timeout/error) migra a <Static> junto a su mensaje
32
+ // ancla, de modo que los mensajes nuevos fluyen debajo.
33
+ const cardActive = !!connectCard && (connectCard.status === 'waiting' || connectCard.status === 'activating');
34
+ const cardInScrollback = !!connectCard && !cardActive;
35
+ // Mensajes que deben quedar en el dynamic frame:
36
+ // - el ultimo si sigue streameando
37
+ // - cualquiera con tool steps en running
38
+ // - el ancla de una tarjeta de conexion ACTIVA
36
39
  const dynamicMsgIds = new Set();
37
40
  const last = messages[messages.length - 1];
38
41
  if (last?.streaming)
@@ -41,11 +44,10 @@ export function ChatLog() {
41
44
  if (step.status === 'running')
42
45
  dynamicMsgIds.add(step.afterMessageId);
43
46
  }
44
- if (waConnect)
45
- dynamicMsgIds.add(waConnect.anchorMessageId);
46
- // Build the static (scrollback) list. Each message and step gets a unique
47
- // key so React/Ink only writes it once.
47
+ if (cardActive && connectCard)
48
+ dynamicMsgIds.add(connectCard.anchorMessageId);
48
49
  const staticItems = [];
50
+ let cardInjected = false;
49
51
  for (const m of messages) {
50
52
  if (dynamicMsgIds.has(m.id))
51
53
  continue;
@@ -53,7 +55,33 @@ export function ChatLog() {
53
55
  for (const s of stepsByMsg.get(m.id) ?? []) {
54
56
  staticItems.push({ kind: 'step', key: `s-${s.id}`, step: s });
55
57
  }
58
+ if (cardInScrollback && connectCard && connectCard.anchorMessageId === m.id) {
59
+ staticItems.push({
60
+ kind: 'connectCard',
61
+ key: `cc-${connectCard.id}-${connectCard.status}`,
62
+ state: connectCard,
63
+ });
64
+ cardInjected = true;
65
+ }
66
+ }
67
+ // Fallback: la tarjeta terminal quedo huerfana porque su mensaje ancla ya no
68
+ // esta (p.ej. tras /compact). La empujamos igual al scrollback para no perder
69
+ // el registro visual.
70
+ if (cardInScrollback && connectCard && !cardInjected) {
71
+ staticItems.push({
72
+ kind: 'connectCard',
73
+ key: `cc-${connectCard.id}-${connectCard.status}-orphan`,
74
+ state: connectCard,
75
+ });
56
76
  }
57
77
  const dynamicMessages = messages.filter((m) => dynamicMsgIds.has(m.id));
58
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => item.kind === 'message' ? (_jsx(Message, { message: item.message }, item.key)) : (_jsx(ToolCall, { step: item.step }, item.key)) }), _jsxs(Box, { flexDirection: "column", children: [dynamicMessages.map((m) => (_jsxs(React.Fragment, { children: [_jsx(Message, { message: m }), (stepsByMsg.get(m.id) ?? []).map((s) => (_jsx(ToolCall, { step: s }, s.id))), waConnect && waConnect.anchorMessageId === m.id ? (_jsx(WhatsappConnectCard, { state: waConnect })) : null] }, m.id))), waConnect && !messages.some((m) => m.id === waConnect.anchorMessageId) ? (_jsx(WhatsappConnectCard, { state: waConnect })) : null] })] }));
78
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => {
79
+ if (item.kind === 'message') {
80
+ return _jsx(Message, { message: item.message }, item.key);
81
+ }
82
+ if (item.kind === 'step') {
83
+ return _jsx(ToolCall, { step: item.step }, item.key);
84
+ }
85
+ return _jsx(ConnectCard, { state: item.state }, item.key);
86
+ } }), _jsxs(Box, { flexDirection: "column", children: [dynamicMessages.map((m) => (_jsxs(React.Fragment, { children: [_jsx(Message, { message: m }), (stepsByMsg.get(m.id) ?? []).map((s) => (_jsx(ToolCall, { step: s }, s.id))), cardActive && connectCard && connectCard.anchorMessageId === m.id ? (_jsx(ConnectCard, { state: connectCard })) : null] }, m.id))), cardActive && connectCard && !messages.some((m) => m.id === connectCard.anchorMessageId) ? (_jsx(ConnectCard, { state: connectCard })) : null] })] }));
59
87
  }
@@ -0,0 +1,85 @@
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { CONNECT_POLL_MAX_ATTEMPTS } from '../connect/types.js';
5
+ function formatExpiry(iso) {
6
+ if (!iso)
7
+ return '';
8
+ const d = new Date(iso);
9
+ if (Number.isNaN(d.getTime()))
10
+ return '';
11
+ return d.toLocaleString('en-US', {
12
+ day: 'numeric',
13
+ month: 'short',
14
+ hour: '2-digit',
15
+ minute: '2-digit',
16
+ });
17
+ }
18
+ /**
19
+ * Envuelve una URL en un hyperlink OSC 8. Terminales que no lo soportan
20
+ * imprimen solo el label visible — iTerm2, kitty, Wezterm, Alacritty (reciente),
21
+ * VS Code/Hyper modernos si lo soportan.
22
+ */
23
+ function osc8(url, label) {
24
+ return `\u001b]8;;${url}\u001b\\${label}\u001b]8;;\u001b\\`;
25
+ }
26
+ function copyFor(channel) {
27
+ switch (channel) {
28
+ case 'whatsapp':
29
+ return {
30
+ headerWaiting: 'Connect your WhatsApp',
31
+ headerConnected: 'WhatsApp connected',
32
+ typeLabel: (lt) => (lt === 'whatsapp_business' ? 'WhatsApp Business' : 'WhatsApp Cloud API'),
33
+ hint: "Open the link from a phone with WhatsApp installed. We'll keep watching for the new number.",
34
+ connectedTpl: (n) => `Number ${n || '(connected)'} activated successfully.`,
35
+ };
36
+ case 'instagram':
37
+ return {
38
+ headerWaiting: 'Connect your Instagram',
39
+ headerConnected: 'Instagram connected',
40
+ typeLabel: () => 'Instagram Business',
41
+ hint: "Open the link in your browser and authorize the Instagram Business account linked to a Facebook Page. We'll keep watching for the connection.",
42
+ connectedTpl: (n) => `Account ${n || '(connected)'} activated successfully.`,
43
+ };
44
+ case 'messenger':
45
+ return {
46
+ headerWaiting: 'Connect your Messenger',
47
+ headerConnected: 'Messenger connected',
48
+ typeLabel: () => 'Facebook Page',
49
+ hint: "Open the link in your browser and select the Facebook Page(s) you want to use with Messenger. We'll keep watching for the connection.",
50
+ connectedTpl: (n) => `Page ${n || '(connected)'} activated successfully.`,
51
+ };
52
+ }
53
+ }
54
+ export function ConnectCard({ state }) {
55
+ const { channel, linkData, status, connectedLabel, errorMessage, attempt } = state;
56
+ const shortUrl = linkData.shortUrl || '';
57
+ const expiry = formatExpiry(linkData.expiresAt);
58
+ const copy = copyFor(channel);
59
+ const typeLabel = copy.typeLabel(linkData.linkType);
60
+ let headerIcon = '●';
61
+ let headerColor = 'green';
62
+ let headerText = copy.headerWaiting;
63
+ if (status === 'connected') {
64
+ headerIcon = '✓';
65
+ headerColor = 'green';
66
+ headerText = copy.headerConnected;
67
+ }
68
+ else if (status === 'error') {
69
+ headerIcon = '✖';
70
+ headerColor = 'red';
71
+ headerText = 'Could not activate the integration';
72
+ }
73
+ else if (status === 'timeout') {
74
+ headerIcon = '○';
75
+ headerColor = 'yellow';
76
+ headerText = 'No connection detected';
77
+ }
78
+ else if (status === 'cancelled') {
79
+ headerIcon = '○';
80
+ headerColor = 'yellow';
81
+ headerText = 'Connection cancelled';
82
+ }
83
+ const showLink = status !== 'connected' && status !== 'cancelled' && !!shortUrl;
84
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: status === 'error' ? 'red' : status === 'connected' ? 'green' : 'gray', paddingX: 1, marginY: 1, marginLeft: 2, children: [_jsxs(Box, { children: [_jsxs(Text, { bold: true, color: headerColor, children: [headerIcon, " "] }), _jsx(Text, { bold: true, children: headerText }), status !== 'connected' && status !== 'error' && status !== 'cancelled' ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " " }), _jsxs(Text, { dimColor: true, children: ["\u00B7 ", typeLabel] })] })) : null] }), showLink ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "link: " }), _jsx(Text, { color: "cyan", underline: true, children: osc8(shortUrl, shortUrl) })] }), expiry ? (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["expires: ", expiry] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: copy.hint }) })] })) : null, _jsxs(Box, { marginTop: 1, children: [status === 'waiting' ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Waiting for connection\u2026 " }), _jsxs(Text, { dimColor: true, children: ["(", attempt, "/", CONNECT_POLL_MAX_ATTEMPTS, ")"] }), _jsx(Text, { dimColor: true, children: " \u00B7 press Esc to cancel" })] })) : null, status === 'activating' ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { dimColor: true, children: " Connection detected, activating\u2026" })] })) : null, status === 'connected' ? (_jsx(Text, { color: "green", children: copy.connectedTpl(connectedLabel || '') })) : null, status === 'timeout' ? (_jsx(Text, { dimColor: true, children: "We didn't detect the connection within 5 minutes. Ask the agent to try again." })) : null, status === 'cancelled' ? (_jsx(Text, { dimColor: true, children: "Polling cancelled by user." })) : null, status === 'error' ? (_jsx(Text, { color: "red", children: errorMessage || 'An error occurred while activating the integration.' })) : null] })] }));
85
+ }
@@ -7,5 +7,5 @@ function fmt(n) {
7
7
  return String(n);
8
8
  }
9
9
  export function Footer({ inputTokens, outputTokens, streaming }) {
10
- return (_jsxs(Box, { paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "tokens: " }), _jsx(Text, { children: fmt(inputTokens) }), _jsx(Text, { dimColor: true, children: " in / " }), _jsx(Text, { children: fmt(outputTokens) }), _jsx(Text, { dimColor: true, children: " out" }), streaming ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " streaming\u2026" })] })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "/help \u00B7 Esc cancel \u00B7 Ctrl+C quit" }) })] }));
10
+ return (_jsxs(Box, { paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "tokens: " }), _jsx(Text, { children: fmt(inputTokens) }), _jsx(Text, { dimColor: true, children: " in / " }), _jsx(Text, { children: fmt(outputTokens) }), _jsx(Text, { dimColor: true, children: " out" }), streaming ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: "yellow", children: " streaming\u2026" })] })) : null] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "/help \u00B7 \u2191\u2193 history \u00B7 Tab accept \u00B7 Esc cancel \u00B7 Ctrl+C quit" }) })] }));
11
11
  }
@@ -1,15 +1,147 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { Box, Text } from 'ink';
4
- import TextInput from 'ink-text-input';
2
+ import { useMemo, useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { useStudioStore } from '../state/store.js';
5
+ /**
6
+ * Caja de texto del REPL. Implementación custom (sin ink-text-input) para poder
7
+ * soportar:
8
+ *
9
+ * - Historial estilo bash: ↑/↓ navega por mensajes previos del usuario.
10
+ * - Ghost text estilo zsh autosuggestions: muestra en gris la coincidencia
11
+ * más reciente del historial cuyo prefijo es lo que el usuario ya tipeó.
12
+ * Se acepta con Tab o → (solo cuando el cursor está al final del texto).
13
+ *
14
+ * Esc y Ctrl+C no se manejan aquí — burbujean al useInput global de App para
15
+ * que cancele stream / haga exit, sin cambiar el comportamiento existente.
16
+ */
5
17
  export function Input({ disabled, onSubmit }) {
6
18
  const [value, setValue] = useState('');
7
- return (_jsxs(Box, { borderStyle: "round", borderColor: disabled ? 'gray' : 'green', paddingX: 1, children: [_jsxs(Text, { color: disabled ? 'gray' : 'green', bold: true, children: [disabled ? '·' : '›', ' '] }), _jsx(TextInput, { value: value, onChange: disabled ? () => { } : setValue, onSubmit: (v) => {
8
- if (disabled)
9
- return;
10
- if (!v.trim())
11
- return;
12
- setValue('');
13
- onSubmit(v);
14
- }, placeholder: disabled ? 'Waiting for response… (press Esc to cancel)' : 'Type a message or /help', showCursor: !disabled })] }));
19
+ const [cursor, setCursor] = useState(0);
20
+ // -1 = no estamos navegando historial (ghost activo, texto editable libre).
21
+ // 0 = mensaje más reciente; aumenta hacia mensajes más viejos.
22
+ const [historyIdx, setHistoryIdx] = useState(-1);
23
+ // Historial derivado del store: solo mensajes del usuario, deduplicados
24
+ // consecutivamente, más recientes primero.
25
+ const history = useStudioStore((s) => {
26
+ const out = [];
27
+ for (let i = s.messages.length - 1; i >= 0; i--) {
28
+ const m = s.messages[i];
29
+ if (m.role !== 'user')
30
+ continue;
31
+ if (out[out.length - 1] === m.content)
32
+ continue;
33
+ out.push(m.content);
34
+ }
35
+ return out;
36
+ });
37
+ // Ghost: primer match del historial cuyo prefijo es `value`. Solo se muestra
38
+ // mientras NO estemos navegando con flechas (para no confundir al usuario).
39
+ const ghost = useMemo(() => {
40
+ if (!value || historyIdx >= 0)
41
+ return '';
42
+ for (const h of history) {
43
+ if (h.length > value.length && h.startsWith(value)) {
44
+ return h.slice(value.length);
45
+ }
46
+ }
47
+ return '';
48
+ }, [value, history, historyIdx]);
49
+ const resetAfterSubmit = () => {
50
+ setValue('');
51
+ setCursor(0);
52
+ setHistoryIdx(-1);
53
+ };
54
+ useInput((input, key) => {
55
+ if (disabled)
56
+ return;
57
+ // Enter → submit
58
+ if (key.return) {
59
+ if (!value.trim())
60
+ return;
61
+ const toSend = value;
62
+ resetAfterSubmit();
63
+ onSubmit(toSend);
64
+ return;
65
+ }
66
+ // ↑ → mensaje más viejo
67
+ if (key.upArrow) {
68
+ if (history.length === 0)
69
+ return;
70
+ const next = Math.min(historyIdx + 1, history.length - 1);
71
+ const text = history[next];
72
+ setHistoryIdx(next);
73
+ setValue(text);
74
+ setCursor(text.length);
75
+ return;
76
+ }
77
+ // ↓ → mensaje más reciente / volver al input vacío
78
+ if (key.downArrow) {
79
+ if (historyIdx <= 0) {
80
+ setHistoryIdx(-1);
81
+ setValue('');
82
+ setCursor(0);
83
+ return;
84
+ }
85
+ const next = historyIdx - 1;
86
+ const text = history[next];
87
+ setHistoryIdx(next);
88
+ setValue(text);
89
+ setCursor(text.length);
90
+ return;
91
+ }
92
+ // Tab → aceptar ghost completo
93
+ if (key.tab) {
94
+ if (ghost) {
95
+ const full = value + ghost;
96
+ setValue(full);
97
+ setCursor(full.length);
98
+ }
99
+ return;
100
+ }
101
+ // → → mover cursor; si está al final y hay ghost, acepta el ghost
102
+ if (key.rightArrow) {
103
+ if (cursor < value.length) {
104
+ setCursor(cursor + 1);
105
+ return;
106
+ }
107
+ if (ghost) {
108
+ const full = value + ghost;
109
+ setValue(full);
110
+ setCursor(full.length);
111
+ }
112
+ return;
113
+ }
114
+ // ← → mover cursor
115
+ if (key.leftArrow) {
116
+ if (cursor > 0)
117
+ setCursor(cursor - 1);
118
+ return;
119
+ }
120
+ // Backspace / Delete → borrar antes del cursor
121
+ if (key.backspace || key.delete) {
122
+ if (cursor === 0)
123
+ return;
124
+ const next = value.slice(0, cursor - 1) + value.slice(cursor);
125
+ setValue(next);
126
+ setCursor(cursor - 1);
127
+ if (historyIdx >= 0)
128
+ setHistoryIdx(-1);
129
+ return;
130
+ }
131
+ // Esc, Ctrl+*, Meta → no consumir, los maneja App.tsx
132
+ if (key.escape || key.ctrl || key.meta)
133
+ return;
134
+ // Char inputs (incluye pegado multibyte). Filtramos secuencias raras
135
+ // que Ink a veces entrega como `input` vacío.
136
+ if (input && input.length > 0) {
137
+ // Si veníamos de navegar historial y el usuario escribe algo, salimos
138
+ // del modo navegación y empezamos a editar normalmente.
139
+ if (historyIdx >= 0)
140
+ setHistoryIdx(-1);
141
+ const next = value.slice(0, cursor) + input + value.slice(cursor);
142
+ setValue(next);
143
+ setCursor(cursor + input.length);
144
+ }
145
+ }, { isActive: !disabled });
146
+ return (_jsxs(Box, { borderStyle: "round", borderColor: disabled ? 'gray' : 'green', paddingX: 1, children: [_jsxs(Text, { color: disabled ? 'gray' : 'green', bold: true, children: [disabled ? '·' : '›', ' '] }), disabled ? (_jsx(Text, { dimColor: true, children: "Waiting for response\u2026 (press Esc to cancel)" })) : value.length === 0 ? (_jsx(Text, { dimColor: true, children: "Type a message or /help" })) : (_jsxs(Text, { children: [_jsx(Text, { children: value.slice(0, cursor) }), _jsx(Text, { inverse: true, children: value.slice(cursor, cursor + 1) || ' ' }), _jsx(Text, { children: value.slice(cursor + 1) }), ghost ? _jsx(Text, { dimColor: true, children: ghost }) : null] }))] }));
15
147
  }
@@ -0,0 +1,66 @@
1
+ import { getBaseUrl } from '../api/studioApi.js';
2
+ import { platformCodeForChannel } from './types.js';
3
+ function headers(opts) {
4
+ const h = {
5
+ 'Content-Type': 'application/json',
6
+ Authorization: `Bearer ${opts.apiKey}`,
7
+ 'x-workspace-id': opts.workspaceId,
8
+ };
9
+ if (opts.userId)
10
+ h['x-user-id'] = opts.userId;
11
+ return h;
12
+ }
13
+ /**
14
+ * GET /api/workspace/{workspaceId} — el backend a veces devuelve un array
15
+ * (el front usa `[0]`). Devuelve el primer elemento o null.
16
+ */
17
+ export async function getWorkspaceById(opts, signal) {
18
+ const url = `${getBaseUrl(opts.zone, opts.dev)}/api/workspace/${encodeURIComponent(opts.workspaceId)}`;
19
+ const res = await fetch(url, { method: 'GET', headers: headers(opts), signal });
20
+ if (!res.ok) {
21
+ throw new Error(`GET workspace ${res.status} ${res.statusText}`);
22
+ }
23
+ const json = (await res.json());
24
+ if (!json)
25
+ return null;
26
+ if (Array.isArray(json))
27
+ return json[0] ?? null;
28
+ return json;
29
+ }
30
+ /**
31
+ * POST /api/workspace/{workspaceId}/integrations/{integrationId}/activate
32
+ * Body: { isActive: true, platformCode }
33
+ *
34
+ * `platformCode` se deriva del canal logico (whatsapp/instagram/facebook).
35
+ */
36
+ export async function activateIntegration(opts, channel, integrationId, signal) {
37
+ const url = `${getBaseUrl(opts.zone, opts.dev)}/api/workspace/${encodeURIComponent(opts.workspaceId)}/integrations/${encodeURIComponent(integrationId)}/activate`;
38
+ const res = await fetch(url, {
39
+ method: 'POST',
40
+ headers: headers(opts),
41
+ body: JSON.stringify({ isActive: true, platformCode: platformCodeForChannel(channel) }),
42
+ signal,
43
+ });
44
+ const text = await res.text();
45
+ let parsed = {};
46
+ if (text) {
47
+ try {
48
+ parsed = JSON.parse(text);
49
+ }
50
+ catch { /* not json */ }
51
+ }
52
+ if (!res.ok) {
53
+ return { success: false, message: parsed.message || `HTTP ${res.status} ${res.statusText}` };
54
+ }
55
+ return parsed.success === false ? parsed : { success: true, ...parsed };
56
+ }
57
+ /** Adapter: deriva las opciones de fetch desde un StudioStreamOptions. */
58
+ export function workspaceOptsFromStream(stream) {
59
+ return {
60
+ apiKey: stream.apiKey,
61
+ workspaceId: stream.workspaceId,
62
+ zone: stream.zone,
63
+ dev: stream.dev,
64
+ userId: stream.userId,
65
+ };
66
+ }
@@ -0,0 +1,76 @@
1
+ import { activateIntegration, getWorkspaceById } from './api.js';
2
+ import { CONNECT_POLL_INTERVAL_MS, CONNECT_POLL_MAX_ATTEMPTS, describeIntegration, findNewIntegrationForChannel, } from './types.js';
3
+ /**
4
+ * Inicia el polling de conexion de canal. Devuelve un handle para cancelarlo.
5
+ * El loop:
6
+ * 1. GET workspace cada CONNECT_POLL_INTERVAL_MS hasta CONNECT_POLL_MAX_ATTEMPTS.
7
+ * 2. Busca una integracion del `channel` cuyo id NO este en `existingIntegrationIds`.
8
+ * 3. Cuando la encuentra, POST .../activate y reporta el resultado.
9
+ */
10
+ export function startConnectPolling(opts, channel, linkData, cb) {
11
+ const knownIds = new Set(linkData.existingIntegrationIds ?? []);
12
+ const controller = new AbortController();
13
+ let attempts = 0;
14
+ let busy = false;
15
+ let stopped = false;
16
+ let timer = null;
17
+ const stop = () => {
18
+ stopped = true;
19
+ if (timer) {
20
+ clearInterval(timer);
21
+ timer = null;
22
+ }
23
+ controller.abort();
24
+ };
25
+ const tick = async () => {
26
+ if (busy || stopped)
27
+ return;
28
+ busy = true;
29
+ attempts++;
30
+ cb.onAttempt(attempts);
31
+ try {
32
+ const ws = await getWorkspaceById(opts, controller.signal);
33
+ const newIntegration = findNewIntegrationForChannel(channel, ws?.integrations, knownIds);
34
+ if (newIntegration?.id) {
35
+ const label = describeIntegration(channel, newIntegration);
36
+ cb.onActivating(newIntegration.id, label);
37
+ if (timer) {
38
+ clearInterval(timer);
39
+ timer = null;
40
+ }
41
+ const res = await activateIntegration(opts, channel, newIntegration.id, controller.signal);
42
+ if (res.success !== false) {
43
+ cb.onConnected(label);
44
+ }
45
+ else {
46
+ cb.onError(res.message || 'Could not activate the integration.');
47
+ }
48
+ stopped = true;
49
+ return;
50
+ }
51
+ }
52
+ catch (err) {
53
+ if (err.name === 'AbortError')
54
+ return;
55
+ // Network transient: swallow and keep polling.
56
+ }
57
+ finally {
58
+ busy = false;
59
+ }
60
+ if (attempts >= CONNECT_POLL_MAX_ATTEMPTS) {
61
+ if (timer) {
62
+ clearInterval(timer);
63
+ timer = null;
64
+ }
65
+ stopped = true;
66
+ cb.onTimeout();
67
+ }
68
+ };
69
+ timer = setInterval(() => { void tick(); }, CONNECT_POLL_INTERVAL_MS);
70
+ // Primer attempt inmediato para feedback visual.
71
+ void tick();
72
+ return {
73
+ cancel() { stop(); },
74
+ get stopped() { return stopped; },
75
+ };
76
+ }