plazbot-cli 0.3.3 → 0.3.5

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tipos y helpers del flujo "Conectar canal desde Agent Studio".
3
+ *
4
+ * El backend emite `tool_result` con `type` = `whatsapp_link` |
5
+ * `instagram_link` | `messenger_link` segun la tool invocada. Todos comparten
6
+ * el mismo shape de datos (link corto + expiracion + baseline de integraciones)
7
+ * y el mismo patron de polling (GET workspace cada 5s hasta detectar el nuevo
8
+ * id) y activacion (POST .../activate).
9
+ *
10
+ * Este modulo es la version generalizada de lo que antes vivia en
11
+ * `src/studio/whatsapp/` cuando solo existia WhatsApp.
12
+ */
13
+ /** Mapea el `type` del tool_result al canal logico del cliente. */
14
+ export function channelFromToolResultType(type) {
15
+ if (type === 'whatsapp_link')
16
+ return 'whatsapp';
17
+ if (type === 'instagram_link')
18
+ return 'instagram';
19
+ if (type === 'messenger_link')
20
+ return 'messenger';
21
+ return null;
22
+ }
23
+ /** Tipos de integracion (workspace.integrations[].type) a vigilar por canal. */
24
+ export function integrationTypesForChannel(channel) {
25
+ switch (channel) {
26
+ case 'whatsapp': return ['whatsapp', 'whatsapp_business'];
27
+ case 'instagram': return ['instagram'];
28
+ case 'messenger': return ['facebook']; // Messenger se guarda como type=facebook
29
+ }
30
+ }
31
+ /**
32
+ * `platformCode` que espera POST .../activate. Para WhatsApp el endpoint usa
33
+ * este valor para una validacion de duplicados; para IG/Messenger no ejecuta
34
+ * ninguna logica especifica pero debe enviarse algo coherente.
35
+ */
36
+ export function platformCodeForChannel(channel) {
37
+ switch (channel) {
38
+ case 'whatsapp': return 'whatsapp';
39
+ case 'instagram': return 'instagram';
40
+ case 'messenger': return 'facebook';
41
+ }
42
+ }
43
+ export const CONNECT_POLL_INTERVAL_MS = 5000;
44
+ export const CONNECT_POLL_MAX_ATTEMPTS = 60; // 5 minutos
45
+ /**
46
+ * Devuelve la integracion mas reciente del canal cuyo id NO este en el baseline.
47
+ * Si hay varias nuevas, gana la de `creationDate` mayor.
48
+ */
49
+ export function findNewIntegrationForChannel(channel, integrations, knownIds) {
50
+ if (!integrations?.length)
51
+ return null;
52
+ const types = new Set(integrationTypesForChannel(channel));
53
+ const candidates = integrations.filter((i) => !!i.type && types.has(i.type) && !!i.id && !knownIds.has(i.id));
54
+ if (!candidates.length)
55
+ return null;
56
+ return candidates.reduce((latest, current) => {
57
+ const a = latest.creationDate ? new Date(latest.creationDate).getTime() : 0;
58
+ const b = current.creationDate ? new Date(current.creationDate).getTime() : 0;
59
+ return b > a ? current : latest;
60
+ });
61
+ }
62
+ /** Etiqueta humana del item recien conectado (numero, alias de pagina, etc.). */
63
+ export function describeIntegration(channel, i) {
64
+ switch (channel) {
65
+ case 'whatsapp':
66
+ return i.cellphoneNumberFormat || i.cellphoneNumber || '';
67
+ case 'instagram':
68
+ return i.aliasName || i.instagramPageId || '';
69
+ case 'messenger':
70
+ return i.aliasName || i.facebookPageId || '';
71
+ }
72
+ }
@@ -6,12 +6,11 @@ import { dirname, join } from 'node:path';
6
6
  import { App } from './components/App.js';
7
7
  import { getStoredCredentials } from '../utils/credentials.js';
8
8
  import { logger } from '../utils/logger.js';
9
- // Alternate screen buffer (ANSI). Same trick used by vim, htop, claude code, lazygit.
10
- // The TUI runs in a clean canvas and the original scrollback is restored on exit,
11
- // which eliminates the "ghost frames" Ink leaves behind when the terminal is resized.
12
- const ALT_SCREEN_ENTER = '\x1b[?1049h\x1b[H';
13
- const ALT_SCREEN_EXIT = '\x1b[?1049l';
14
- const SHOW_CURSOR = '\x1b[?25h';
9
+ // Estilo Claude Code: NO usamos alt screen buffer. Razon: en el alt screen el
10
+ // scrollback nativo de la terminal queda inaccesible y el usuario no puede
11
+ // revisar la conversacion anterior. Aceptamos el tradeoff de que en algunos
12
+ // resizes de la terminal pueda haber artifacts visuales temporales (los frames
13
+ // estaticos ya quedaron en scrollback gracias a <Static>).
15
14
  export async function runRepl(opts) {
16
15
  let creds;
17
16
  try {
@@ -36,31 +35,6 @@ export async function runRepl(opts) {
36
35
  dev: opts.dev,
37
36
  };
38
37
  const version = readVersion();
39
- const useAltScreen = !!process.stdout.isTTY;
40
- // Cleanup idempotente; lo registramos en varias señales para que la terminal
41
- // nunca quede en alt screen ni con el cursor oculto si el proceso muere mal.
42
- let cleanedUp = false;
43
- const restoreTerminal = () => {
44
- if (cleanedUp)
45
- return;
46
- cleanedUp = true;
47
- if (useAltScreen) {
48
- process.stdout.write(ALT_SCREEN_EXIT);
49
- process.stdout.write(SHOW_CURSOR);
50
- }
51
- };
52
- if (useAltScreen) {
53
- process.stdout.write(ALT_SCREEN_ENTER);
54
- }
55
- process.on('exit', restoreTerminal);
56
- process.on('SIGHUP', restoreTerminal);
57
- process.on('SIGTERM', restoreTerminal);
58
- process.on('uncaughtException', (err) => {
59
- restoreTerminal();
60
- // Re-emit so node prints the trace after restoring the terminal.
61
- console.error(err);
62
- process.exit(1);
63
- });
64
38
  const { waitUntilExit } = render(_jsx(App, { version: version, stream: stream, initialAgentId: opts.agentId ?? null, dev: opts.dev, supportMode: supportMode }), {
65
39
  exitOnCtrlC: false,
66
40
  });
@@ -68,12 +42,7 @@ export async function runRepl(opts) {
68
42
  process.on('SIGINT', () => {
69
43
  // no-op aquí; App.tsx llama exit() al detectar Ctrl+C.
70
44
  });
71
- try {
72
- await waitUntilExit();
73
- }
74
- finally {
75
- restoreTerminal();
76
- }
45
+ await waitUntilExit();
77
46
  }
78
47
  function readVersion() {
79
48
  try {
@@ -17,7 +17,7 @@ export const useStudioStore = create((set, get) => ({
17
17
  usage: { inputTokens: 0, outputTokens: 0 },
18
18
  streaming: false,
19
19
  abortController: null,
20
- waConnect: null,
20
+ connectCard: null,
21
21
  pendingCompact: false,
22
22
  pushUserMessage(content) {
23
23
  const id = uid();
@@ -92,11 +92,12 @@ export const useStudioStore = create((set, get) => ({
92
92
  if (ctrl && !ctrl.signal.aborted)
93
93
  ctrl.abort();
94
94
  },
95
- startWaConnect(linkData, anchorMessageId) {
95
+ startConnectCard(channel, linkData, anchorMessageId) {
96
96
  const id = uid();
97
97
  set({
98
- waConnect: {
98
+ connectCard: {
99
99
  id,
100
+ channel,
100
101
  linkData,
101
102
  status: 'waiting',
102
103
  anchorMessageId,
@@ -105,14 +106,14 @@ export const useStudioStore = create((set, get) => ({
105
106
  });
106
107
  return id;
107
108
  },
108
- updateWaConnect(patch) {
109
- set((s) => (s.waConnect ? { waConnect: { ...s.waConnect, ...patch } } : {}));
109
+ updateConnectCard(patch) {
110
+ set((s) => (s.connectCard ? { connectCard: { ...s.connectCard, ...patch } } : {}));
110
111
  },
111
- setWaStatus(status, extra) {
112
- set((s) => (s.waConnect ? { waConnect: { ...s.waConnect, status, ...(extra ?? {}) } } : {}));
112
+ setConnectStatus(status, extra) {
113
+ set((s) => (s.connectCard ? { connectCard: { ...s.connectCard, status, ...(extra ?? {}) } } : {}));
113
114
  },
114
- clearWaConnect() {
115
- set({ waConnect: null });
115
+ clearConnectCard() {
116
+ set({ connectCard: null });
116
117
  },
117
118
  clearMessages() {
118
119
  const ctrl = get().abortController;
@@ -123,7 +124,7 @@ export const useStudioStore = create((set, get) => ({
123
124
  steps: [],
124
125
  streaming: false,
125
126
  abortController: null,
126
- waConnect: null,
127
+ connectCard: null,
127
128
  pendingCompact: false,
128
129
  // agentConfig, agentId, usage se mantienen para seguir trabajando.
129
130
  });
@@ -147,7 +148,7 @@ export const useStudioStore = create((set, get) => ({
147
148
  },
148
149
  ],
149
150
  steps: [],
150
- waConnect: null,
151
+ connectCard: null,
151
152
  pendingCompact: false,
152
153
  };
153
154
  });
@@ -164,7 +165,7 @@ export const useStudioStore = create((set, get) => ({
164
165
  usage: { inputTokens: 0, outputTokens: 0 },
165
166
  streaming: false,
166
167
  abortController: null,
167
- waConnect: null,
168
+ connectCard: null,
168
169
  pendingCompact: false,
169
170
  });
170
171
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plazbot-cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "CLI para Plazbot SDK",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -12,9 +12,9 @@ import { slashRegistry } from '../slash/registry.js';
12
12
  import { streamStudio } from '../api/sseClient.js';
13
13
  import { StudioHttpError } from '../api/types.js';
14
14
  import type { StudioChunk, StudioStreamOptions } from '../api/types.js';
15
- import { startWhatsappPolling, type PollingHandle } from '../whatsapp/polling.js';
16
- import { workspaceOptsFromStream } from '../whatsapp/api.js';
17
- import type { WhatsappLinkData } from '../whatsapp/types.js';
15
+ import { startConnectPolling, type PollingHandle } from '../connect/polling.js';
16
+ import { workspaceOptsFromStream } from '../connect/api.js';
17
+ import { channelFromToolResultType, type ChannelLinkData } from '../connect/types.js';
18
18
 
19
19
  export interface AppProps {
20
20
  version: string;
@@ -31,18 +31,18 @@ export function App({ version, stream, initialAgentId, dev, supportMode }: AppPr
31
31
  const streaming = useStudioStore((s) => s.streaming);
32
32
  const agentId = useStudioStore((s) => s.agentId);
33
33
 
34
- // Active WhatsApp polling handle (cancelled on Esc / unmount / new stream).
35
- const waPollingRef = useRef<PollingHandle | null>(null);
34
+ // Active channel-connect polling handle (cancelled on Esc / unmount / new stream).
35
+ const connectPollingRef = useRef<PollingHandle | null>(null);
36
36
  const streamingRef = useRef(streaming);
37
37
  useEffect(() => { streamingRef.current = streaming; }, [streaming]);
38
38
  // Ref so the polling onConnected callback can trigger sendToBackend from the
39
39
  // most recent closure without resubscribing every render.
40
40
  const sendBackendRef = useRef<(text: string) => Promise<void>>(async () => {});
41
41
 
42
- const stopWaPolling = useCallback(() => {
43
- if (waPollingRef.current) {
44
- waPollingRef.current.cancel();
45
- waPollingRef.current = null;
42
+ const stopConnectPolling = useCallback(() => {
43
+ if (connectPollingRef.current) {
44
+ connectPollingRef.current.cancel();
45
+ connectPollingRef.current = null;
46
46
  }
47
47
  }, []);
48
48
 
@@ -56,23 +56,23 @@ export function App({ version, stream, initialAgentId, dev, supportMode }: AppPr
56
56
 
57
57
  // Cleanup on unmount.
58
58
  useEffect(() => {
59
- return () => { stopWaPolling(); };
60
- }, [stopWaPolling]);
59
+ return () => { stopConnectPolling(); };
60
+ }, [stopConnectPolling]);
61
61
 
62
62
  // Atajos de teclado globales.
63
63
  useInput((input, key) => {
64
64
  if (key.escape) {
65
- const wa = useStudioStore.getState().waConnect;
66
- if (wa && (wa.status === 'waiting' || wa.status === 'activating')) {
67
- stopWaPolling();
68
- useStudioStore.getState().setWaStatus('cancelled');
65
+ const cc = useStudioStore.getState().connectCard;
66
+ if (cc && (cc.status === 'waiting' || cc.status === 'activating')) {
67
+ stopConnectPolling();
68
+ useStudioStore.getState().setConnectStatus('cancelled');
69
69
  return;
70
70
  }
71
71
  useStudioStore.getState().abortStream();
72
72
  return;
73
73
  }
74
74
  if (key.ctrl && input === 'c') {
75
- stopWaPolling();
75
+ stopConnectPolling();
76
76
  useStudioStore.getState().abortStream();
77
77
  exit();
78
78
  return;
@@ -116,41 +116,49 @@ export function App({ version, stream, initialAgentId, dev, supportMode }: AppPr
116
116
  if (typeof idCandidate === 'string') s.setAgentId(idCandidate);
117
117
  }
118
118
  }
119
- // WhatsApp connect tool: spin up the card + polling.
120
- if (tr?.type === 'whatsapp_link' && data) {
121
- const linkData = data as WhatsappLinkData;
119
+ // Channel connect tool (whatsapp / instagram / messenger): spin up the
120
+ // card + polling. The chunk discriminator (`whatsapp_link`,
121
+ // `instagram_link`, `messenger_link`) tells us which channel.
122
+ const channel = channelFromToolResultType(tr?.type);
123
+ if (channel && data) {
124
+ const linkData = data as ChannelLinkData;
122
125
  // No link to show (e.g. alreadyConnected without a new url) → skip card.
123
126
  if (linkData.shortUrl) {
124
127
  // Cancel any previous active polling before starting a new one.
125
- stopWaPolling();
126
- s.startWaConnect(linkData, assistantId);
127
- waPollingRef.current = startWhatsappPolling(
128
+ stopConnectPolling();
129
+ s.startConnectCard(channel, linkData, assistantId);
130
+ connectPollingRef.current = startConnectPolling(
128
131
  workspaceOptsFromStream(stream),
132
+ channel,
129
133
  linkData,
130
134
  {
131
135
  onAttempt(attempt) {
132
- useStudioStore.getState().updateWaConnect({ attempt });
136
+ useStudioStore.getState().updateConnectCard({ attempt });
133
137
  },
134
138
  onActivating() {
135
- useStudioStore.getState().setWaStatus('activating');
139
+ useStudioStore.getState().setConnectStatus('activating');
136
140
  },
137
- onConnected(number) {
138
- useStudioStore.getState().setWaStatus('connected', { connectedNumber: number });
139
- waPollingRef.current = null;
141
+ onConnected(label) {
142
+ useStudioStore.getState().setConnectStatus('connected', { connectedLabel: label });
143
+ connectPollingRef.current = null;
140
144
  if (!streamingRef.current) {
145
+ const channelName =
146
+ channel === 'whatsapp' ? 'WhatsApp'
147
+ : channel === 'instagram' ? 'Instagram'
148
+ : 'Messenger';
141
149
  const followUp =
142
- `My WhatsApp is now connected. Number: ${number || '(no number)'}. ` +
150
+ `My ${channelName} is now connected. ${label ? `Label: ${label}.` : ''} ` +
143
151
  `Continue with the agent setup.`;
144
152
  void sendBackendRef.current(followUp);
145
153
  }
146
154
  },
147
155
  onTimeout() {
148
- useStudioStore.getState().setWaStatus('timeout');
149
- waPollingRef.current = null;
156
+ useStudioStore.getState().setConnectStatus('timeout');
157
+ connectPollingRef.current = null;
150
158
  },
151
159
  onError(message) {
152
- useStudioStore.getState().setWaStatus('error', { errorMessage: message });
153
- waPollingRef.current = null;
160
+ useStudioStore.getState().setConnectStatus('error', { errorMessage: message });
161
+ connectPollingRef.current = null;
154
162
  },
155
163
  },
156
164
  );
@@ -195,7 +203,7 @@ export function App({ version, stream, initialAgentId, dev, supportMode }: AppPr
195
203
  useStudioStore.getState().compactInto(assistantId);
196
204
  }
197
205
  }
198
- }, [stream, dev, stopWaPolling]);
206
+ }, [stream, dev, stopConnectPolling]);
199
207
 
200
208
  useEffect(() => { sendBackendRef.current = sendToBackend; }, [sendToBackend]);
201
209
 
@@ -2,33 +2,32 @@ import React from 'react';
2
2
  import { Box, Static } from 'ink';
3
3
  import { useStudioStore } from '../state/store.js';
4
4
  import type { ChatMessage, ToolStep } from '../state/store.js';
5
+ import type { ConnectState } from '../connect/types.js';
5
6
  import { Message } from './Message.js';
6
7
  import { ToolCall } from './ToolCall.js';
7
- import { WhatsappConnectCard } from './WhatsappConnectCard.js';
8
+ import { ConnectCard } from './ConnectCard.js';
8
9
 
9
10
  type HistoricItem =
10
11
  | { kind: 'message'; key: string; message: ChatMessage }
11
- | { kind: 'step'; key: string; step: ToolStep };
12
+ | { kind: 'step'; key: string; step: ToolStep }
13
+ | { kind: 'connectCard'; key: string; state: ConnectState };
12
14
 
13
15
  /**
14
- * Renders the conversation log.
16
+ * Renderiza el log de la conversacion.
15
17
  *
16
- * Stable items (finished assistant/user messages + resolved tool steps that
17
- * are not anchored to an active WhatsApp card) are rendered inside <Static>
18
- * so Ink writes them to scrollback once and never re-renders them on resize.
19
- * This is what fixes the "Header echoed multiple times" effect you see when
20
- * dragging the terminal window while the dynamic frame is taller than the
21
- * viewport.
18
+ * Items estables (mensajes finalizados + tool steps resueltos que no estan
19
+ * anclados a una tarjeta de conexion activa) van dentro de <Static> para que
20
+ * Ink los escriba al scrollback una sola vez y no los re-renderice en resize.
22
21
  *
23
- * The dynamic block at the bottom contains the streaming message, any steps
24
- * still in `running`, and the WhatsApp connect card while it is active.
22
+ * El bloque dinamico al final contiene el mensaje en streaming, los steps que
23
+ * siguen en `running`, y la tarjeta de conexion (whatsapp/instagram/messenger)
24
+ * mientras esta activa.
25
25
  */
26
26
  export function ChatLog(): React.ReactElement {
27
27
  const messages = useStudioStore((s) => s.messages);
28
28
  const steps = useStudioStore((s) => s.steps);
29
- const waConnect = useStudioStore((s) => s.waConnect);
29
+ const connectCard = useStudioStore((s) => s.connectCard);
30
30
 
31
- // Group steps by their anchor message for quick lookup.
32
31
  const stepsByMsg = new Map<string, ToolStep[]>();
33
32
  for (const step of steps) {
34
33
  const arr = stepsByMsg.get(step.afterMessageId) ?? [];
@@ -36,27 +35,52 @@ export function ChatLog(): React.ReactElement {
36
35
  stepsByMsg.set(step.afterMessageId, arr);
37
36
  }
38
37
 
39
- // Determine which message ids must stay in the dynamic frame:
40
- // - the last message if it's still streaming
41
- // - any message whose tool steps are still running
42
- // - the message anchored to an active WhatsApp connect card
38
+ // La tarjeta solo permanece en el dynamic frame mientras esta "viva"
39
+ // (polling activo o activando). En estados terminales
40
+ // (connected/cancelled/timeout/error) migra a <Static> junto a su mensaje
41
+ // ancla, de modo que los mensajes nuevos fluyen debajo.
42
+ const cardActive =
43
+ !!connectCard && (connectCard.status === 'waiting' || connectCard.status === 'activating');
44
+ const cardInScrollback = !!connectCard && !cardActive;
45
+
46
+ // Mensajes que deben quedar en el dynamic frame:
47
+ // - el ultimo si sigue streameando
48
+ // - cualquiera con tool steps en running
49
+ // - el ancla de una tarjeta de conexion ACTIVA
43
50
  const dynamicMsgIds = new Set<string>();
44
51
  const last = messages[messages.length - 1];
45
52
  if (last?.streaming) dynamicMsgIds.add(last.id);
46
53
  for (const step of steps) {
47
54
  if (step.status === 'running') dynamicMsgIds.add(step.afterMessageId);
48
55
  }
49
- if (waConnect) dynamicMsgIds.add(waConnect.anchorMessageId);
56
+ if (cardActive && connectCard) dynamicMsgIds.add(connectCard.anchorMessageId);
50
57
 
51
- // Build the static (scrollback) list. Each message and step gets a unique
52
- // key so React/Ink only writes it once.
53
58
  const staticItems: HistoricItem[] = [];
59
+ let cardInjected = false;
54
60
  for (const m of messages) {
55
61
  if (dynamicMsgIds.has(m.id)) continue;
56
62
  staticItems.push({ kind: 'message', key: `m-${m.id}`, message: m });
57
63
  for (const s of stepsByMsg.get(m.id) ?? []) {
58
64
  staticItems.push({ kind: 'step', key: `s-${s.id}`, step: s });
59
65
  }
66
+ if (cardInScrollback && connectCard && connectCard.anchorMessageId === m.id) {
67
+ staticItems.push({
68
+ kind: 'connectCard',
69
+ key: `cc-${connectCard.id}-${connectCard.status}`,
70
+ state: connectCard,
71
+ });
72
+ cardInjected = true;
73
+ }
74
+ }
75
+ // Fallback: la tarjeta terminal quedo huerfana porque su mensaje ancla ya no
76
+ // esta (p.ej. tras /compact). La empujamos igual al scrollback para no perder
77
+ // el registro visual.
78
+ if (cardInScrollback && connectCard && !cardInjected) {
79
+ staticItems.push({
80
+ kind: 'connectCard',
81
+ key: `cc-${connectCard.id}-${connectCard.status}-orphan`,
82
+ state: connectCard,
83
+ });
60
84
  }
61
85
 
62
86
  const dynamicMessages = messages.filter((m) => dynamicMsgIds.has(m.id));
@@ -64,13 +88,15 @@ export function ChatLog(): React.ReactElement {
64
88
  return (
65
89
  <Box flexDirection="column">
66
90
  <Static items={staticItems}>
67
- {(item) =>
68
- item.kind === 'message' ? (
69
- <Message key={item.key} message={item.message} />
70
- ) : (
71
- <ToolCall key={item.key} step={item.step} />
72
- )
73
- }
91
+ {(item) => {
92
+ if (item.kind === 'message') {
93
+ return <Message key={item.key} message={item.message} />;
94
+ }
95
+ if (item.kind === 'step') {
96
+ return <ToolCall key={item.key} step={item.step} />;
97
+ }
98
+ return <ConnectCard key={item.key} state={item.state} />;
99
+ }}
74
100
  </Static>
75
101
 
76
102
  <Box flexDirection="column">
@@ -80,14 +106,14 @@ export function ChatLog(): React.ReactElement {
80
106
  {(stepsByMsg.get(m.id) ?? []).map((s) => (
81
107
  <ToolCall key={s.id} step={s} />
82
108
  ))}
83
- {waConnect && waConnect.anchorMessageId === m.id ? (
84
- <WhatsappConnectCard state={waConnect} />
109
+ {cardActive && connectCard && connectCard.anchorMessageId === m.id ? (
110
+ <ConnectCard state={connectCard} />
85
111
  ) : null}
86
112
  </React.Fragment>
87
113
  ))}
88
- {/* Fallback: waConnect anchor message was cleared. */}
89
- {waConnect && !messages.some((m) => m.id === waConnect.anchorMessageId) ? (
90
- <WhatsappConnectCard state={waConnect} />
114
+ {/* Fallback: tarjeta activa cuyo ancla ya no esta en messages. */}
115
+ {cardActive && connectCard && !messages.some((m) => m.id === connectCard.anchorMessageId) ? (
116
+ <ConnectCard state={connectCard} />
91
117
  ) : null}
92
118
  </Box>
93
119
  </Box>
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
2
  import { Box, Text } from 'ink';
3
3
  import Spinner from 'ink-spinner';
4
- import type { WaConnectState } from '../whatsapp/types.js';
5
- import { WA_POLL_MAX_ATTEMPTS } from '../whatsapp/types.js';
4
+ import type { ConnectChannel, ConnectState } from '../connect/types.js';
5
+ import { CONNECT_POLL_MAX_ATTEMPTS } from '../connect/types.js';
6
6
 
7
7
  interface Props {
8
- state: WaConnectState;
8
+ state: ConnectState;
9
9
  }
10
10
 
11
11
  function formatExpiry(iso?: string): string {
@@ -21,33 +21,69 @@ function formatExpiry(iso?: string): string {
21
21
  }
22
22
 
23
23
  /**
24
- * Wraps a URL in an OSC 8 hyperlink escape sequence. Terminals that don't
25
- * support it just print the visible text but iTerm2, kitty, Wezterm,
26
- * Alacritty (recent), and modern VS Code/Hyper terminals do.
24
+ * Envuelve una URL en un hyperlink OSC 8. Terminales que no lo soportan
25
+ * imprimen solo el label visible — iTerm2, kitty, Wezterm, Alacritty (reciente),
26
+ * VS Code/Hyper modernos si lo soportan.
27
27
  */
28
28
  function osc8(url: string, label: string): string {
29
29
  return `\u001b]8;;${url}\u001b\\${label}\u001b]8;;\u001b\\`;
30
30
  }
31
31
 
32
- export function WhatsappConnectCard({ state }: Props): React.ReactElement {
33
- const { linkData, status, connectedNumber, errorMessage, attempt } = state;
32
+ interface ChannelCopy {
33
+ headerWaiting: string;
34
+ headerConnected: string;
35
+ typeLabel: (linkType: string | undefined) => string;
36
+ hint: string;
37
+ connectedTpl: (label: string) => string;
38
+ }
39
+
40
+ function copyFor(channel: ConnectChannel): ChannelCopy {
41
+ switch (channel) {
42
+ case 'whatsapp':
43
+ return {
44
+ headerWaiting: 'Connect your WhatsApp',
45
+ headerConnected: 'WhatsApp connected',
46
+ typeLabel: (lt) => (lt === 'whatsapp_business' ? 'WhatsApp Business' : 'WhatsApp Cloud API'),
47
+ hint: "Open the link from a phone with WhatsApp installed. We'll keep watching for the new number.",
48
+ connectedTpl: (n) => `Number ${n || '(connected)'} activated successfully.`,
49
+ };
50
+ case 'instagram':
51
+ return {
52
+ headerWaiting: 'Connect your Instagram',
53
+ headerConnected: 'Instagram connected',
54
+ typeLabel: () => 'Instagram Business',
55
+ 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.",
56
+ connectedTpl: (n) => `Account ${n || '(connected)'} activated successfully.`,
57
+ };
58
+ case 'messenger':
59
+ return {
60
+ headerWaiting: 'Connect your Messenger',
61
+ headerConnected: 'Messenger connected',
62
+ typeLabel: () => 'Facebook Page',
63
+ 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.",
64
+ connectedTpl: (n) => `Page ${n || '(connected)'} activated successfully.`,
65
+ };
66
+ }
67
+ }
68
+
69
+ export function ConnectCard({ state }: Props): React.ReactElement {
70
+ const { channel, linkData, status, connectedLabel, errorMessage, attempt } = state;
34
71
  const shortUrl = linkData.shortUrl || '';
35
72
  const expiry = formatExpiry(linkData.expiresAt);
36
- const typeLabel =
37
- linkData.linkType === 'whatsapp_business' ? 'WhatsApp Business' : 'WhatsApp Cloud API';
73
+ const copy = copyFor(channel);
74
+ const typeLabel = copy.typeLabel(linkData.linkType);
38
75
 
39
- // Header glyph + color per status.
40
76
  let headerIcon = '●';
41
77
  let headerColor: 'green' | 'red' | 'yellow' = 'green';
42
- let headerText = 'Connect your WhatsApp';
78
+ let headerText = copy.headerWaiting;
43
79
  if (status === 'connected') {
44
80
  headerIcon = '✓';
45
81
  headerColor = 'green';
46
- headerText = 'WhatsApp connected';
82
+ headerText = copy.headerConnected;
47
83
  } else if (status === 'error') {
48
84
  headerIcon = '✖';
49
85
  headerColor = 'red';
50
- headerText = 'Could not activate the number';
86
+ headerText = 'Could not activate the integration';
51
87
  } else if (status === 'timeout') {
52
88
  headerIcon = '○';
53
89
  headerColor = 'yellow';
@@ -94,9 +130,7 @@ export function WhatsappConnectCard({ state }: Props): React.ReactElement {
94
130
  </Box>
95
131
  ) : null}
96
132
  <Box marginTop={1}>
97
- <Text dimColor>
98
- Open the link from a phone with WhatsApp installed. We'll keep watching for the new number.
99
- </Text>
133
+ <Text dimColor>{copy.hint}</Text>
100
134
  </Box>
101
135
  </Box>
102
136
  ) : null}
@@ -107,20 +141,18 @@ export function WhatsappConnectCard({ state }: Props): React.ReactElement {
107
141
  <>
108
142
  <Text color="yellow"><Spinner type="dots" /></Text>
109
143
  <Text dimColor> Waiting for connection… </Text>
110
- <Text dimColor>({attempt}/{WA_POLL_MAX_ATTEMPTS})</Text>
144
+ <Text dimColor>({attempt}/{CONNECT_POLL_MAX_ATTEMPTS})</Text>
111
145
  <Text dimColor> · press Esc to cancel</Text>
112
146
  </>
113
147
  ) : null}
114
148
  {status === 'activating' ? (
115
149
  <>
116
150
  <Text color="yellow"><Spinner type="dots" /></Text>
117
- <Text dimColor> Number detected, activating…</Text>
151
+ <Text dimColor> Connection detected, activating…</Text>
118
152
  </>
119
153
  ) : null}
120
154
  {status === 'connected' ? (
121
- <Text color="green">
122
- Number {connectedNumber || '(connected)'} activated successfully.
123
- </Text>
155
+ <Text color="green">{copy.connectedTpl(connectedLabel || '')}</Text>
124
156
  ) : null}
125
157
  {status === 'timeout' ? (
126
158
  <Text dimColor>
@@ -31,7 +31,7 @@ export function Footer({ inputTokens, outputTokens, streaming }: FooterProps): R
31
31
  ) : null}
32
32
  </Box>
33
33
  <Box>
34
- <Text dimColor>/help · Esc cancel · Ctrl+C quit</Text>
34
+ <Text dimColor>/help · ↑↓ history · Tab accept · Esc cancel · Ctrl+C quit</Text>
35
35
  </Box>
36
36
  </Box>
37
37
  );