plazbot-cli 0.2.26 → 0.3.2

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.
Files changed (178) hide show
  1. package/CLAUDE.md +34 -5
  2. package/README.md +21 -0
  3. package/dist/cli.js +32 -20
  4. package/dist/commands/agent/ai-config.js +98 -50
  5. package/dist/commands/agent/chat.js +80 -74
  6. package/dist/commands/agent/copy.js +23 -21
  7. package/dist/commands/agent/create.js +42 -72
  8. package/dist/commands/agent/delete.js +29 -30
  9. package/dist/commands/agent/enable-widget.js +30 -26
  10. package/dist/commands/agent/export.js +90 -77
  11. package/dist/commands/agent/files.js +68 -60
  12. package/dist/commands/agent/get.js +101 -87
  13. package/dist/commands/agent/index.js +53 -39
  14. package/dist/commands/agent/list.js +26 -24
  15. package/dist/commands/agent/monitor.js +91 -86
  16. package/dist/commands/agent/on-message.js +40 -37
  17. package/dist/commands/agent/set.js +62 -59
  18. package/dist/commands/agent/templates.js +109 -108
  19. package/dist/commands/agent/tools.js +64 -65
  20. package/dist/commands/agent/update.js +28 -27
  21. package/dist/commands/agent/validate.js +127 -0
  22. package/dist/commands/agent/wizard.js +152 -159
  23. package/dist/commands/auth/index.js +7 -10
  24. package/dist/commands/auth/login.js +50 -37
  25. package/dist/commands/auth/logout.js +16 -14
  26. package/dist/commands/auth/status.js +19 -16
  27. package/dist/commands/portal/add-agent.js +26 -24
  28. package/dist/commands/portal/add-link.js +21 -17
  29. package/dist/commands/portal/clear-links.js +17 -15
  30. package/dist/commands/portal/create.js +25 -21
  31. package/dist/commands/portal/delete.js +31 -30
  32. package/dist/commands/portal/get.js +33 -31
  33. package/dist/commands/portal/index.js +30 -22
  34. package/dist/commands/portal/list.js +34 -30
  35. package/dist/commands/portal/update.js +41 -33
  36. package/dist/commands/whatsapp/broadcast.js +40 -37
  37. package/dist/commands/whatsapp/channels.js +40 -34
  38. package/dist/commands/whatsapp/chat.js +33 -32
  39. package/dist/commands/whatsapp/connect.js +53 -52
  40. package/dist/commands/whatsapp/delete-webhook.js +19 -17
  41. package/dist/commands/whatsapp/index.js +35 -25
  42. package/dist/commands/whatsapp/register-webhook.js +21 -19
  43. package/dist/commands/whatsapp/send-template.js +39 -31
  44. package/dist/commands/whatsapp/send.js +27 -23
  45. package/dist/commands/whatsapp/widget.js +35 -31
  46. package/dist/commands/workers/deploy.js +49 -44
  47. package/dist/commands/workers/index.js +28 -18
  48. package/dist/commands/workers/list.js +43 -35
  49. package/dist/commands/workers/logs.js +38 -32
  50. package/dist/commands/workers/remove.js +38 -37
  51. package/dist/commands/workers/secret.js +63 -58
  52. package/dist/commands/workers/test.js +44 -36
  53. package/dist/schemas/agent.config.schema.json +569 -0
  54. package/dist/studio/api/sseClient.js +97 -0
  55. package/dist/studio/api/studioApi.js +25 -0
  56. package/dist/studio/api/types.js +16 -0
  57. package/dist/studio/components/AgentPanel.js +35 -0
  58. package/dist/studio/components/App.js +214 -0
  59. package/dist/studio/components/ChatLog.js +59 -0
  60. package/dist/studio/components/Footer.js +11 -0
  61. package/dist/studio/components/Header.js +8 -0
  62. package/dist/studio/components/Input.js +15 -0
  63. package/dist/studio/components/Message.js +56 -0
  64. package/dist/studio/components/Suggestions.js +11 -0
  65. package/dist/studio/components/ToolCall.js +33 -0
  66. package/dist/studio/components/WhatsappConnectCard.js +57 -0
  67. package/dist/studio/index.js +42 -0
  68. package/dist/studio/render/json.js +16 -0
  69. package/dist/studio/render/markdown.js +86 -0
  70. package/dist/studio/render/steps.js +58 -0
  71. package/dist/studio/runOneShot.js +96 -0
  72. package/dist/studio/runRepl.js +52 -0
  73. package/dist/studio/slash/handlers.js +199 -0
  74. package/dist/studio/slash/parser.js +46 -0
  75. package/dist/studio/slash/registry.js +16 -0
  76. package/dist/studio/state/store.js +181 -0
  77. package/dist/studio/whatsapp/api.js +63 -0
  78. package/dist/studio/whatsapp/polling.js +77 -0
  79. package/dist/studio/whatsapp/types.js +31 -0
  80. package/dist/types/agent.js +1 -2
  81. package/dist/types/auth.js +1 -2
  82. package/dist/types/common.js +1 -2
  83. package/dist/types/message.js +1 -2
  84. package/dist/types/portal.js +1 -2
  85. package/dist/types/workers.js +1 -2
  86. package/dist/utils/agent-errors.js +46 -0
  87. package/dist/utils/api.js +8 -9
  88. package/dist/utils/banner.js +33 -34
  89. package/dist/utils/credentials.js +12 -20
  90. package/dist/utils/help.js +44 -0
  91. package/dist/utils/logger.js +13 -19
  92. package/dist/utils/ui.js +35 -49
  93. package/package.json +22 -10
  94. package/src/cli.ts +24 -8
  95. package/src/commands/agent/ai-config.ts +89 -34
  96. package/src/commands/agent/chat.ts +49 -37
  97. package/src/commands/agent/copy.ts +19 -13
  98. package/src/commands/agent/create.ts +32 -22
  99. package/src/commands/agent/delete.ts +24 -18
  100. package/src/commands/agent/enable-widget.ts +31 -23
  101. package/src/commands/agent/export.ts +72 -51
  102. package/src/commands/agent/files.ts +51 -39
  103. package/src/commands/agent/get.ts +86 -66
  104. package/src/commands/agent/index.ts +36 -18
  105. package/src/commands/agent/list.ts +22 -16
  106. package/src/commands/agent/monitor.ts +67 -56
  107. package/src/commands/agent/on-message.ts +36 -27
  108. package/src/commands/agent/set.ts +47 -37
  109. package/src/commands/agent/templates.ts +90 -82
  110. package/src/commands/agent/tools.ts +53 -47
  111. package/src/commands/agent/update.ts +28 -20
  112. package/src/commands/agent/validate.ts +135 -0
  113. package/src/commands/agent/wizard.ts +114 -114
  114. package/src/commands/auth/index.ts +3 -3
  115. package/src/commands/auth/login.ts +44 -29
  116. package/src/commands/auth/logout.ts +16 -10
  117. package/src/commands/auth/status.ts +14 -8
  118. package/src/commands/portal/add-agent.ts +23 -17
  119. package/src/commands/portal/add-link.ts +17 -9
  120. package/src/commands/portal/clear-links.ts +13 -7
  121. package/src/commands/portal/create.ts +20 -12
  122. package/src/commands/portal/delete.ts +28 -20
  123. package/src/commands/portal/get.ts +25 -19
  124. package/src/commands/portal/index.ts +22 -10
  125. package/src/commands/portal/list.ts +27 -19
  126. package/src/commands/portal/update.ts +38 -26
  127. package/src/commands/whatsapp/broadcast.ts +28 -18
  128. package/src/commands/whatsapp/channels.ts +31 -20
  129. package/src/commands/whatsapp/chat.ts +20 -12
  130. package/src/commands/whatsapp/connect.ts +39 -31
  131. package/src/commands/whatsapp/delete-webhook.ts +15 -9
  132. package/src/commands/whatsapp/index.ts +24 -10
  133. package/src/commands/whatsapp/register-webhook.ts +16 -10
  134. package/src/commands/whatsapp/send-template.ts +33 -21
  135. package/src/commands/whatsapp/send.ts +23 -15
  136. package/src/commands/whatsapp/widget.ts +25 -17
  137. package/src/commands/workers/deploy.ts +34 -22
  138. package/src/commands/workers/index.ts +21 -7
  139. package/src/commands/workers/list.ts +31 -19
  140. package/src/commands/workers/logs.ts +30 -20
  141. package/src/commands/workers/remove.ts +30 -22
  142. package/src/commands/workers/secret.ts +46 -34
  143. package/src/commands/workers/test.ts +34 -22
  144. package/src/schemas/agent.config.schema.json +569 -0
  145. package/src/studio/api/sseClient.ts +91 -0
  146. package/src/studio/api/studioApi.ts +27 -0
  147. package/src/studio/api/types.ts +96 -0
  148. package/src/studio/components/App.tsx +266 -0
  149. package/src/studio/components/ChatLog.tsx +95 -0
  150. package/src/studio/components/Footer.tsx +38 -0
  151. package/src/studio/components/Header.tsx +39 -0
  152. package/src/studio/components/Input.tsx +32 -0
  153. package/src/studio/components/Message.tsx +87 -0
  154. package/src/studio/components/Suggestions.tsx +26 -0
  155. package/src/studio/components/ToolCall.tsx +58 -0
  156. package/src/studio/components/WhatsappConnectCard.tsx +139 -0
  157. package/src/studio/index.ts +58 -0
  158. package/src/studio/render/markdown.ts +93 -0
  159. package/src/studio/render/steps.ts +57 -0
  160. package/src/studio/runOneShot.ts +114 -0
  161. package/src/studio/runRepl.tsx +76 -0
  162. package/src/studio/slash/handlers.ts +226 -0
  163. package/src/studio/slash/parser.ts +41 -0
  164. package/src/studio/slash/registry.ts +54 -0
  165. package/src/studio/state/store.ts +273 -0
  166. package/src/studio/whatsapp/api.ts +96 -0
  167. package/src/studio/whatsapp/polling.ts +93 -0
  168. package/src/studio/whatsapp/types.ts +80 -0
  169. package/src/types/agent.ts +1 -1
  170. package/src/types/auth.ts +4 -3
  171. package/src/types/portal.ts +1 -1
  172. package/src/types/workers.ts +1 -1
  173. package/src/utils/agent-errors.ts +67 -0
  174. package/src/utils/api.ts +6 -0
  175. package/src/utils/banner.ts +14 -9
  176. package/src/utils/credentials.ts +6 -5
  177. package/src/utils/help.ts +51 -0
  178. package/tsconfig.json +9 -6
@@ -0,0 +1,27 @@
1
+ import type { StudioStreamOptions } from './types.js';
2
+
3
+ /**
4
+ * Construye la URL base según zona / modo dev.
5
+ * LA -> https://api.plazbot.com
6
+ * EU -> https://apieu.plazbot.com
7
+ * dev -> http://localhost:5090
8
+ */
9
+ export function getBaseUrl(zone: 'LA' | 'EU', dev?: boolean): string {
10
+ if (dev) return 'http://localhost:5090';
11
+ return zone === 'EU' ? 'https://apieu.plazbot.com' : 'https://api.plazbot.com';
12
+ }
13
+
14
+ export function buildStudioUrl(zone: 'LA' | 'EU', dev?: boolean): string {
15
+ return `${getBaseUrl(zone, dev)}/api/agent/studio`;
16
+ }
17
+
18
+ export function buildStudioHeaders(opts: StudioStreamOptions): Record<string, string> {
19
+ const headers: Record<string, string> = {
20
+ 'Content-Type': 'application/json',
21
+ Accept: 'text/event-stream',
22
+ Authorization: `Bearer ${opts.apiKey}`,
23
+ 'x-workspace-id': opts.workspaceId,
24
+ };
25
+ if (opts.userId) headers['x-user-id'] = opts.userId;
26
+ return headers;
27
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Tipos de los chunks que emite el endpoint POST /api/agent/studio (SSE).
3
+ * Espejo del modelo del backend AgentStudioModels.cs.
4
+ */
5
+
6
+ export type StudioChunkType = 'text' | 'tool_call' | 'tool_result' | 'usage' | 'done' | 'error';
7
+
8
+ export interface StudioChunkText {
9
+ type: 'text';
10
+ content: string;
11
+ }
12
+
13
+ export interface StudioChunkToolCall {
14
+ type: 'tool_call';
15
+ tool_name: string;
16
+ tool_args?: unknown;
17
+ }
18
+
19
+ /** Resultado genérico devuelto por una tool (espejo de ToolExecutionResult.cs). */
20
+ export interface ToolExecutionResult {
21
+ success: boolean;
22
+ tool?: string;
23
+ type?: string; // "agent_config" | "agent_list" | "diagnostic" | "agent_loaded" | ...
24
+ data?: unknown;
25
+ message?: string;
26
+ error?: string;
27
+ }
28
+
29
+ export interface StudioChunkToolResult {
30
+ type: 'tool_result';
31
+ tool_name: string;
32
+ tool_result?: ToolExecutionResult;
33
+ }
34
+
35
+ export interface StudioChunkUsage {
36
+ type: 'usage';
37
+ input_tokens: number;
38
+ output_tokens: number;
39
+ }
40
+
41
+ export interface StudioChunkDone {
42
+ type: 'done';
43
+ }
44
+
45
+ export interface StudioChunkError {
46
+ type: 'error';
47
+ error: string;
48
+ }
49
+
50
+ export type StudioChunk =
51
+ | StudioChunkText
52
+ | StudioChunkToolCall
53
+ | StudioChunkToolResult
54
+ | StudioChunkUsage
55
+ | StudioChunkDone
56
+ | StudioChunkError;
57
+
58
+ /**
59
+ * Mensaje conversacional enviado al backend en el array `messages`.
60
+ */
61
+ export interface StudioMessageIn {
62
+ role: 'user' | 'assistant';
63
+ content: string;
64
+ }
65
+
66
+ /**
67
+ * Payload del request POST /api/agent/studio.
68
+ * NB: el backend valida `message` (singular, string) y devuelve 400 si está vacío.
69
+ * `messages` lleva el historial; `message` lleva el último turno del usuario.
70
+ */
71
+ export interface StudioRequest {
72
+ message: string;
73
+ messages: StudioMessageIn[];
74
+ agentId?: string | null;
75
+ agentConfig?: unknown;
76
+ workspaceId?: string;
77
+ userId?: string;
78
+ }
79
+
80
+ /**
81
+ * Opciones para el cliente SSE (autenticación + ruteo).
82
+ */
83
+ export interface StudioStreamOptions {
84
+ apiKey: string; // JWT
85
+ workspaceId: string;
86
+ userId?: string;
87
+ zone: 'LA' | 'EU';
88
+ dev?: boolean;
89
+ }
90
+
91
+ export class StudioHttpError extends Error {
92
+ constructor(public status: number, public statusText: string, public body?: string) {
93
+ super(`HTTP ${status} ${statusText}`);
94
+ this.name = 'StudioHttpError';
95
+ }
96
+ }
@@ -0,0 +1,266 @@
1
+ import React, { useCallback, useEffect, useRef } from 'react';
2
+ import { Box, useApp, useInput } from 'ink';
3
+ import { Header } from './Header.js';
4
+ import { Footer } from './Footer.js';
5
+ import { ChatLog } from './ChatLog.js';
6
+ import { Input } from './Input.js';
7
+ import { Suggestions } from './Suggestions.js';
8
+ import { useStudioStore, selectBackendMessages } from '../state/store.js';
9
+ import { parseSlash } from '../slash/parser.js';
10
+ import '../slash/handlers.js'; // side effect: registra slashes
11
+ import { slashRegistry } from '../slash/registry.js';
12
+ import { streamStudio } from '../api/sseClient.js';
13
+ import { StudioHttpError } from '../api/types.js';
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';
18
+
19
+ export interface AppProps {
20
+ version: string;
21
+ stream: StudioStreamOptions;
22
+ initialAgentId?: string | null;
23
+ dev: boolean;
24
+ supportMode?: boolean;
25
+ }
26
+
27
+ export function App({ version, stream, initialAgentId, dev, supportMode }: AppProps): React.ReactElement {
28
+ const { exit } = useApp();
29
+ const messages = useStudioStore((s) => s.messages);
30
+ const usage = useStudioStore((s) => s.usage);
31
+ const streaming = useStudioStore((s) => s.streaming);
32
+ const agentId = useStudioStore((s) => s.agentId);
33
+
34
+ // Active WhatsApp polling handle (cancelled on Esc / unmount / new stream).
35
+ const waPollingRef = useRef<PollingHandle | null>(null);
36
+ const streamingRef = useRef(streaming);
37
+ useEffect(() => { streamingRef.current = streaming; }, [streaming]);
38
+ // Ref so the polling onConnected callback can trigger sendToBackend from the
39
+ // most recent closure without resubscribing every render.
40
+ const sendBackendRef = useRef<(text: string) => Promise<void>>(async () => {});
41
+
42
+ const stopWaPolling = useCallback(() => {
43
+ if (waPollingRef.current) {
44
+ waPollingRef.current.cancel();
45
+ waPollingRef.current = null;
46
+ }
47
+ }, []);
48
+
49
+ // Cargar agente inicial si se pasó por flag.
50
+ useEffect(() => {
51
+ if (initialAgentId) {
52
+ useStudioStore.getState().setAgentId(initialAgentId);
53
+ }
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, []);
56
+
57
+ // Cleanup on unmount.
58
+ useEffect(() => {
59
+ return () => { stopWaPolling(); };
60
+ }, [stopWaPolling]);
61
+
62
+ // Atajos de teclado globales.
63
+ useInput((input, key) => {
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');
69
+ return;
70
+ }
71
+ useStudioStore.getState().abortStream();
72
+ return;
73
+ }
74
+ if (key.ctrl && input === 'c') {
75
+ stopWaPolling();
76
+ useStudioStore.getState().abortStream();
77
+ exit();
78
+ return;
79
+ }
80
+ });
81
+
82
+ const sendToBackend = useCallback(async (userText: string) => {
83
+ const store = useStudioStore.getState();
84
+ // Snapshot del historial ANTES de agregar el nuevo user message, igual que
85
+ // el front (AgentStudioModal.tsx:504). Si se calcula después, el último
86
+ // user input queda duplicado (en `message` y en `messages`).
87
+ const historyForApi = selectBackendMessages();
88
+ store.pushUserMessage(userText);
89
+
90
+ const assistantId = store.startAssistantMessage();
91
+ const controller = new AbortController();
92
+ store.setAbortController(controller);
93
+ store.setStreaming(true);
94
+
95
+ const onChunk = (chunk: StudioChunk) => {
96
+ const s = useStudioStore.getState();
97
+ switch (chunk.type) {
98
+ case 'text':
99
+ s.appendAssistantText(assistantId, chunk.content ?? '');
100
+ break;
101
+ case 'tool_call':
102
+ s.startStep(chunk.tool_name, assistantId);
103
+ break;
104
+ case 'tool_result': {
105
+ const tr = chunk.tool_result;
106
+ const success = tr?.success !== false && !tr?.error;
107
+ s.resolveLastStep(chunk.tool_name, success ? 'success' : 'error', tr?.data, tr?.error);
108
+ // Stash the agent config in state (used as `agentConfig` context on
109
+ // the next request) and remember its id. We no longer render it.
110
+ const data = tr?.data as Record<string, unknown> | undefined;
111
+ if (data) {
112
+ const cfg = data.config ?? data.agentConfig ?? data.agent ?? null;
113
+ if (cfg && typeof cfg === 'object') {
114
+ s.setAgentConfig(cfg);
115
+ const idCandidate = (cfg as Record<string, unknown>).id;
116
+ if (typeof idCandidate === 'string') s.setAgentId(idCandidate);
117
+ }
118
+ }
119
+ // WhatsApp connect tool: spin up the card + polling.
120
+ if (tr?.type === 'whatsapp_link' && data) {
121
+ const linkData = data as WhatsappLinkData;
122
+ // No link to show (e.g. alreadyConnected without a new url) → skip card.
123
+ if (linkData.shortUrl) {
124
+ // Cancel any previous active polling before starting a new one.
125
+ stopWaPolling();
126
+ s.startWaConnect(linkData, assistantId);
127
+ waPollingRef.current = startWhatsappPolling(
128
+ workspaceOptsFromStream(stream),
129
+ linkData,
130
+ {
131
+ onAttempt(attempt) {
132
+ useStudioStore.getState().updateWaConnect({ attempt });
133
+ },
134
+ onActivating() {
135
+ useStudioStore.getState().setWaStatus('activating');
136
+ },
137
+ onConnected(number) {
138
+ useStudioStore.getState().setWaStatus('connected', { connectedNumber: number });
139
+ waPollingRef.current = null;
140
+ if (!streamingRef.current) {
141
+ const followUp =
142
+ `My WhatsApp is now connected. Number: ${number || '(no number)'}. ` +
143
+ `Continue with the agent setup.`;
144
+ void sendBackendRef.current(followUp);
145
+ }
146
+ },
147
+ onTimeout() {
148
+ useStudioStore.getState().setWaStatus('timeout');
149
+ waPollingRef.current = null;
150
+ },
151
+ onError(message) {
152
+ useStudioStore.getState().setWaStatus('error', { errorMessage: message });
153
+ waPollingRef.current = null;
154
+ },
155
+ },
156
+ );
157
+ }
158
+ }
159
+ break;
160
+ }
161
+ case 'usage':
162
+ s.addUsage(chunk.input_tokens ?? 0, chunk.output_tokens ?? 0);
163
+ break;
164
+ case 'error':
165
+ s.failAllRunningSteps(chunk.error);
166
+ s.pushSyntheticAssistant(`✖ ${chunk.error}`, { error: true });
167
+ break;
168
+ case 'done':
169
+ break;
170
+ }
171
+ };
172
+
173
+ try {
174
+ await streamStudio(
175
+ stream,
176
+ {
177
+ message: userText,
178
+ messages: historyForApi,
179
+ agentId: useStudioStore.getState().agentId,
180
+ agentConfig: useStudioStore.getState().agentConfig,
181
+ },
182
+ onChunk,
183
+ controller.signal,
184
+ );
185
+ } catch (err) {
186
+ const msg = formatError(err, dev);
187
+ useStudioStore.getState().failAllRunningSteps('Network error');
188
+ useStudioStore.getState().pushSyntheticAssistant(msg, { error: true });
189
+ } finally {
190
+ useStudioStore.getState().finishAssistantMessage(assistantId);
191
+ useStudioStore.getState().setStreaming(false);
192
+ useStudioStore.getState().setAbortController(null);
193
+ // /compact flow: replace history with the assistant summary just produced.
194
+ if (useStudioStore.getState().pendingCompact) {
195
+ useStudioStore.getState().compactInto(assistantId);
196
+ }
197
+ }
198
+ }, [stream, dev, stopWaPolling]);
199
+
200
+ useEffect(() => { sendBackendRef.current = sendToBackend; }, [sendToBackend]);
201
+
202
+ const handleSubmit = useCallback(async (raw: string) => {
203
+ const parsed = parseSlash(raw);
204
+ if (parsed) {
205
+ const spec = slashRegistry.get(parsed.name);
206
+ if (!spec) {
207
+ useStudioStore.getState().pushSyntheticAssistant(
208
+ `Unknown command: \`/${parsed.name}\`. Run \`/help\` to see the list.`,
209
+ { error: true },
210
+ );
211
+ return;
212
+ }
213
+ const res = await spec.handler(parsed, { exit });
214
+ if (res.kind === 'message') {
215
+ await sendToBackend(res.content);
216
+ }
217
+ return;
218
+ }
219
+ await sendToBackend(raw);
220
+ }, [sendToBackend, exit]);
221
+
222
+ const empty = messages.length === 0;
223
+
224
+ return (
225
+ <Box flexDirection="column">
226
+ <Header
227
+ version={version}
228
+ workspace={stream.workspaceId}
229
+ zone={stream.zone}
230
+ agentId={agentId}
231
+ dev={dev}
232
+ supportMode={!!supportMode}
233
+ />
234
+
235
+ <Box flexDirection="column" paddingX={1}>
236
+ {empty ? <Suggestions /> : <ChatLog />}
237
+ </Box>
238
+
239
+ <Input disabled={streaming} onSubmit={handleSubmit} />
240
+ <Footer
241
+ inputTokens={usage.inputTokens}
242
+ outputTokens={usage.outputTokens}
243
+ streaming={streaming}
244
+ />
245
+ </Box>
246
+ );
247
+ }
248
+
249
+ function formatError(err: unknown, dev: boolean): string {
250
+ if (err instanceof StudioHttpError) {
251
+ if (err.status === 401) return '✖ Token expired or invalid. Run `plazbot init` with a valid JWT.';
252
+ if (err.status === 403) return '✖ No permission for this workspace.';
253
+ if (err.status === 429) return '✖ Rate limit reached. Wait a few seconds and try again.';
254
+ if (err.status >= 500) {
255
+ return dev
256
+ ? `✖ Backend error ${err.status}.\n\`\`\`\n${err.body ?? err.statusText}\n\`\`\``
257
+ : `✖ The backend returned error ${err.status}. Retry in a few seconds.`;
258
+ }
259
+ return `✖ HTTP ${err.status} ${err.statusText}`;
260
+ }
261
+ if (err instanceof Error) {
262
+ if (err.name === 'AbortError') return '✖ Stream cancelled.';
263
+ return `✖ ${err.message}`;
264
+ }
265
+ return '✖ Unknown error';
266
+ }
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+ import { Box, Static } from 'ink';
3
+ import { useStudioStore } from '../state/store.js';
4
+ import type { ChatMessage, ToolStep } from '../state/store.js';
5
+ import { Message } from './Message.js';
6
+ import { ToolCall } from './ToolCall.js';
7
+ import { WhatsappConnectCard } from './WhatsappConnectCard.js';
8
+
9
+ type HistoricItem =
10
+ | { kind: 'message'; key: string; message: ChatMessage }
11
+ | { kind: 'step'; key: string; step: ToolStep };
12
+
13
+ /**
14
+ * Renders the conversation log.
15
+ *
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.
22
+ *
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.
25
+ */
26
+ export function ChatLog(): React.ReactElement {
27
+ const messages = useStudioStore((s) => s.messages);
28
+ const steps = useStudioStore((s) => s.steps);
29
+ const waConnect = useStudioStore((s) => s.waConnect);
30
+
31
+ // Group steps by their anchor message for quick lookup.
32
+ const stepsByMsg = new Map<string, ToolStep[]>();
33
+ for (const step of steps) {
34
+ const arr = stepsByMsg.get(step.afterMessageId) ?? [];
35
+ arr.push(step);
36
+ stepsByMsg.set(step.afterMessageId, arr);
37
+ }
38
+
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
43
+ const dynamicMsgIds = new Set<string>();
44
+ const last = messages[messages.length - 1];
45
+ if (last?.streaming) dynamicMsgIds.add(last.id);
46
+ for (const step of steps) {
47
+ if (step.status === 'running') dynamicMsgIds.add(step.afterMessageId);
48
+ }
49
+ if (waConnect) dynamicMsgIds.add(waConnect.anchorMessageId);
50
+
51
+ // Build the static (scrollback) list. Each message and step gets a unique
52
+ // key so React/Ink only writes it once.
53
+ const staticItems: HistoricItem[] = [];
54
+ for (const m of messages) {
55
+ if (dynamicMsgIds.has(m.id)) continue;
56
+ staticItems.push({ kind: 'message', key: `m-${m.id}`, message: m });
57
+ for (const s of stepsByMsg.get(m.id) ?? []) {
58
+ staticItems.push({ kind: 'step', key: `s-${s.id}`, step: s });
59
+ }
60
+ }
61
+
62
+ const dynamicMessages = messages.filter((m) => dynamicMsgIds.has(m.id));
63
+
64
+ return (
65
+ <Box flexDirection="column">
66
+ <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
+ }
74
+ </Static>
75
+
76
+ <Box flexDirection="column">
77
+ {dynamicMessages.map((m) => (
78
+ <React.Fragment key={m.id}>
79
+ <Message message={m} />
80
+ {(stepsByMsg.get(m.id) ?? []).map((s) => (
81
+ <ToolCall key={s.id} step={s} />
82
+ ))}
83
+ {waConnect && waConnect.anchorMessageId === m.id ? (
84
+ <WhatsappConnectCard state={waConnect} />
85
+ ) : null}
86
+ </React.Fragment>
87
+ ))}
88
+ {/* Fallback: waConnect anchor message was cleared. */}
89
+ {waConnect && !messages.some((m) => m.id === waConnect.anchorMessageId) ? (
90
+ <WhatsappConnectCard state={waConnect} />
91
+ ) : null}
92
+ </Box>
93
+ </Box>
94
+ );
95
+ }
@@ -0,0 +1,38 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+
5
+ interface FooterProps {
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ streaming: boolean;
9
+ }
10
+
11
+ function fmt(n: number): string {
12
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
13
+ return String(n);
14
+ }
15
+
16
+ export function Footer({ inputTokens, outputTokens, streaming }: FooterProps): React.ReactElement {
17
+ return (
18
+ <Box paddingX={1} flexDirection="row" justifyContent="space-between">
19
+ <Box>
20
+ <Text dimColor>tokens: </Text>
21
+ <Text>{fmt(inputTokens)}</Text>
22
+ <Text dimColor> in / </Text>
23
+ <Text>{fmt(outputTokens)}</Text>
24
+ <Text dimColor> out</Text>
25
+ {streaming ? (
26
+ <>
27
+ <Text> </Text>
28
+ <Text color="yellow"><Spinner type="dots" /></Text>
29
+ <Text color="yellow"> streaming…</Text>
30
+ </>
31
+ ) : null}
32
+ </Box>
33
+ <Box>
34
+ <Text dimColor>/help · Esc cancel · Ctrl+C quit</Text>
35
+ </Box>
36
+ </Box>
37
+ );
38
+ }
@@ -0,0 +1,39 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ interface HeaderProps {
5
+ version: string;
6
+ workspace: string;
7
+ zone: 'LA' | 'EU';
8
+ agentId: string | null;
9
+ dev: boolean;
10
+ supportMode?: boolean;
11
+ }
12
+
13
+ export function Header({ version, workspace, zone, agentId, dev, supportMode }: HeaderProps): React.ReactElement {
14
+ // Sin borde: cualquier `borderStyle` se acumula visualmente cuando el usuario
15
+ // redimensiona la terminal (Ink no limpia el scrollback). Estilo Claude Code:
16
+ // una sola línea con título a la izquierda y metadata a la derecha.
17
+ return (
18
+ <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
19
+ <Box>
20
+ <Text bold color={supportMode ? 'yellow' : 'green'}>● Plazbot Studio</Text>
21
+ <Text dimColor> v{version}</Text>
22
+ {dev ? <Text color="yellow"> dev</Text> : null}
23
+ {supportMode ? <Text color="yellow" bold> · SUPPORT</Text> : null}
24
+ </Box>
25
+ <Box>
26
+ <Text dimColor>workspace </Text>
27
+ <Text color={supportMode ? 'yellow' : undefined}>{workspace}</Text>
28
+ <Text dimColor> zone </Text>
29
+ <Text>{zone}</Text>
30
+ {agentId ? (
31
+ <>
32
+ <Text dimColor> agent </Text>
33
+ <Text color="cyan">{agentId.slice(0, 12)}</Text>
34
+ </>
35
+ ) : null}
36
+ </Box>
37
+ </Box>
38
+ );
39
+ }
@@ -0,0 +1,32 @@
1
+ import React, { useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+
5
+ interface InputProps {
6
+ disabled: boolean;
7
+ onSubmit: (value: string) => void;
8
+ }
9
+
10
+ export function Input({ disabled, onSubmit }: InputProps): React.ReactElement {
11
+ const [value, setValue] = useState('');
12
+
13
+ return (
14
+ <Box borderStyle="round" borderColor={disabled ? 'gray' : 'green'} paddingX={1}>
15
+ <Text color={disabled ? 'gray' : 'green'} bold>
16
+ {disabled ? '·' : '›'}{' '}
17
+ </Text>
18
+ <TextInput
19
+ value={value}
20
+ onChange={disabled ? () => {} : setValue}
21
+ onSubmit={(v: string) => {
22
+ if (disabled) return;
23
+ if (!v.trim()) return;
24
+ setValue('');
25
+ onSubmit(v);
26
+ }}
27
+ placeholder={disabled ? 'Waiting for response… (press Esc to cancel)' : 'Type a message or /help'}
28
+ showCursor={!disabled}
29
+ />
30
+ </Box>
31
+ );
32
+ }
@@ -0,0 +1,87 @@
1
+ import React, { useEffect, useMemo, useState } from 'react';
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import type { ChatMessage } from '../state/store.js';
5
+ import { renderMarkdown } from '../render/markdown.js';
6
+
7
+ interface MessageProps {
8
+ message: ChatMessage;
9
+ }
10
+
11
+ /**
12
+ * Phrases shown next to the spinner while waiting for the first text token.
13
+ * Rotated every ~1.8s for a Claude Code–style feel.
14
+ */
15
+ const THINKING_PHRASES = [
16
+ 'Thinking',
17
+ 'Pondering',
18
+ 'Cooking',
19
+ 'Reasoning',
20
+ 'Crafting',
21
+ 'Working',
22
+ 'Brewing',
23
+ ];
24
+
25
+ function useRotatingPhrase(active: boolean): string {
26
+ const [idx, setIdx] = useState(0);
27
+ useEffect(() => {
28
+ if (!active) return;
29
+ const t = setInterval(() => setIdx((i) => (i + 1) % THINKING_PHRASES.length), 1800);
30
+ return () => clearInterval(t);
31
+ }, [active]);
32
+ return THINKING_PHRASES[idx];
33
+ }
34
+
35
+ function useBlinkingCursor(active: boolean): boolean {
36
+ const [on, setOn] = useState(true);
37
+ useEffect(() => {
38
+ if (!active) return;
39
+ const t = setInterval(() => setOn((v) => !v), 450);
40
+ return () => clearInterval(t);
41
+ }, [active]);
42
+ return on;
43
+ }
44
+
45
+ export function Message({ message }: MessageProps): React.ReactElement {
46
+ const isUser = message.role === 'user';
47
+ const dotColor = isUser ? 'cyan' : message.error ? 'red' : 'green';
48
+ const labelColor = message.error ? 'red' : undefined;
49
+ const label = isUser ? 'You' : 'Plazbot';
50
+ const streamingNoContent = !!message.streaming && !message.content && !isUser;
51
+ const streamingWithContent = !!message.streaming && !!message.content && !isUser;
52
+
53
+ const phrase = useRotatingPhrase(streamingNoContent);
54
+ const cursorOn = useBlinkingCursor(streamingWithContent);
55
+
56
+ const body = useMemo(() => {
57
+ if (!message.content) return '';
58
+ if (isUser) return message.content;
59
+ return renderMarkdown(message.content);
60
+ }, [message.content, isUser]);
61
+
62
+ return (
63
+ <Box flexDirection="column" marginBottom={1}>
64
+ <Box>
65
+ <Text color={dotColor} bold>● </Text>
66
+ <Text bold color={labelColor}>{label}</Text>
67
+ {streamingNoContent ? (
68
+ <>
69
+ <Text dimColor> </Text>
70
+ <Text color="yellow"><Spinner type="dots" /></Text>
71
+ <Text dimColor> {phrase}…</Text>
72
+ </>
73
+ ) : null}
74
+ </Box>
75
+ {body || streamingWithContent ? (
76
+ <Box marginLeft={2}>
77
+ <Text>
78
+ {body}
79
+ {streamingWithContent ? (
80
+ <Text color="green" dimColor>{cursorOn ? '▍' : ' '}</Text>
81
+ ) : null}
82
+ </Text>
83
+ </Box>
84
+ ) : null}
85
+ </Box>
86
+ );
87
+ }