ghostykit 0.4.1 → 0.5.0

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.
package/README.md CHANGED
@@ -109,10 +109,13 @@ ref.current?.send("Why did this fail?"); // ask on the user'
109
109
  ```
110
110
 
111
111
  Other options: `strings`, `toolIcons`, `formatUserText`, `markdown`, `features`
112
- (`attachments`, `retry`, `copy`, `usage`, `replyOptions`, `duration`), `references` /
113
- `onReferencesChange`, `voiceNoteText`, `placeholder`, `avatar`, `initialTurns`, and the callbacks
114
- `onTurnStart`, `onTitle`, `onEvent` (every raw AG-UI event: STATE_*, CUSTOM…), `onSend`. To read the state
115
- outside the chat, call `usePilot(transport)` yourself and pass the result as `pilot`. Every piece is
112
+ (`attachments`, `retry`, `copy`, `usage`, `replyOptions`, `duration`, `steer`), `references` /
113
+ `onReferencesChange`, `voiceNoteText`, `placeholder`, `avatar`, `initialTurns`, `loadOlder` (earlier
114
+ pages of a long thread), the slot `sidePanel`, and the callbacks `onTurnStart`, `onTitle`, `onState`,
115
+ `onEvent` (every raw AG-UI event), `onSend`. The agent's shared state (`STATE_SNAPSHOT` +
116
+ `STATE_DELTA`) is `pilot.state.agentState`, and each turn's `CUSTOM` events are `turn.custom` (what
117
+ `renderDeliveries` draws). To read the state outside the chat, call `usePilot(transport)` yourself
118
+ and pass the result as `pilot`. Every piece is
116
119
  exported (`AgentBubble`, `AgentSteps`, `Composer`, `ConfirmCard`, `Markdown`, …) if you want to build
117
120
  your own layout.
118
121
 
@@ -1,7 +1,120 @@
1
+ // src/core/jsonPatch.ts
2
+ function parsePath(path) {
3
+ if (path === "") return [];
4
+ if (!path.startsWith("/")) throw new Error(`bad path ${path}`);
5
+ return path.slice(1).split("/").map((k) => k.replace(/~1/g, "/").replace(/~0/g, "~"));
6
+ }
7
+ function get(doc, keys) {
8
+ let cur = doc;
9
+ for (const k of keys) {
10
+ if (Array.isArray(cur)) cur = cur[Number(k)];
11
+ else if (cur && typeof cur === "object") cur = cur[k];
12
+ else return void 0;
13
+ }
14
+ return cur;
15
+ }
16
+ function edit(doc, keys, fn) {
17
+ if (!keys.length) throw new Error("root");
18
+ const [k, ...rest] = keys;
19
+ if (!rest.length) return fn(doc, k);
20
+ if (Array.isArray(doc)) {
21
+ const i = Number(k);
22
+ if (!(i in doc)) throw new Error(`missing ${k}`);
23
+ const out = [...doc];
24
+ out[i] = edit(doc[i], rest, fn);
25
+ return out;
26
+ }
27
+ if (doc && typeof doc === "object" && k in doc) {
28
+ return { ...doc, [k]: edit(doc[k], rest, fn) };
29
+ }
30
+ throw new Error(`missing ${k}`);
31
+ }
32
+ function add(doc, keys, value) {
33
+ if (!keys.length) return value;
34
+ return edit(doc, keys, (parent, k) => {
35
+ if (Array.isArray(parent)) {
36
+ const i = k === "-" ? parent.length : Number(k);
37
+ if (!Number.isInteger(i) || i < 0 || i > parent.length) throw new Error(`index ${k}`);
38
+ const out = [...parent];
39
+ out.splice(i, 0, value);
40
+ return out;
41
+ }
42
+ if (parent && typeof parent === "object") return { ...parent, [k]: value };
43
+ throw new Error("not a container");
44
+ });
45
+ }
46
+ function remove(doc, keys) {
47
+ return edit(doc, keys, (parent, k) => {
48
+ if (Array.isArray(parent)) {
49
+ const i = Number(k);
50
+ if (!(i in parent)) throw new Error(`index ${k}`);
51
+ return parent.filter((_, j) => j !== i);
52
+ }
53
+ if (parent && typeof parent === "object" && k in parent) {
54
+ const { [k]: _, ...rest } = parent;
55
+ return rest;
56
+ }
57
+ throw new Error(`missing ${k}`);
58
+ });
59
+ }
60
+ function replace(doc, keys, value) {
61
+ if (!keys.length) return value;
62
+ return edit(doc, keys, (parent, k) => {
63
+ if (Array.isArray(parent)) {
64
+ const i = Number(k);
65
+ if (!(i in parent)) throw new Error(`index ${k}`);
66
+ const out = [...parent];
67
+ out[i] = value;
68
+ return out;
69
+ }
70
+ if (parent && typeof parent === "object" && k in parent) return { ...parent, [k]: value };
71
+ throw new Error(`missing ${k}`);
72
+ });
73
+ }
74
+ var equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
75
+ function applyJsonPatch(doc, ops) {
76
+ let cur = doc;
77
+ try {
78
+ for (const o of ops) {
79
+ const keys = parsePath(o.path);
80
+ if (o.op === "add") cur = add(cur, keys, o.value);
81
+ else if (o.op === "remove") cur = remove(cur, keys);
82
+ else if (o.op === "replace") cur = replace(cur, keys, o.value);
83
+ else if (o.op === "move" || o.op === "copy") {
84
+ const from = parsePath(o.from ?? "");
85
+ const v = get(cur, from);
86
+ if (v === void 0) throw new Error(`missing ${o.from}`);
87
+ if (o.op === "move") cur = remove(cur, from);
88
+ cur = add(cur, keys, v);
89
+ } else if (o.op === "test") {
90
+ if (!equal(get(cur, keys), o.value)) throw new Error(`test ${o.path}`);
91
+ } else throw new Error(`op ${o.op}`);
92
+ }
93
+ return { doc: cur, ok: true };
94
+ } catch {
95
+ return { doc, ok: false };
96
+ }
97
+ }
98
+
1
99
  // src/core/reducer.ts
2
100
  function createPilotState(turns = []) {
3
- return { turns, busy: false, error: null, notice: null, usage: null, permissions: [], turnStartedAt: 0, runId: null, effects: [] };
101
+ return {
102
+ turns,
103
+ busy: false,
104
+ error: null,
105
+ notice: null,
106
+ usage: null,
107
+ permissions: [],
108
+ turnStartedAt: 0,
109
+ runId: null,
110
+ agentState: {},
111
+ step: null,
112
+ history: null,
113
+ olderCount: 0,
114
+ effects: []
115
+ };
4
116
  }
117
+ var PROTOCOL_CUSTOM = /* @__PURE__ */ new Set(["ghosty.title", "ghosty.injected", "ghosty.history"]);
5
118
  function patchAssistant(s, patch) {
6
119
  const turns = [...s.turns];
7
120
  const last = turns[turns.length - 1];
@@ -72,9 +185,9 @@ function attachmentsOf(content) {
72
185
  function messagesToTurns(messages) {
73
186
  const turns = [];
74
187
  let cur = null;
75
- const assistant = () => {
188
+ const assistant = (id) => {
76
189
  if (!cur) {
77
- cur = { role: "assistant", text: "" };
190
+ cur = { ...id ? { id } : {}, role: "assistant", text: "" };
78
191
  turns.push(cur);
79
192
  }
80
193
  return cur;
@@ -82,9 +195,12 @@ function messagesToTurns(messages) {
82
195
  for (const m of messages) {
83
196
  if (m.role === "user") {
84
197
  cur = null;
85
- turns.push({ role: "user", text: textOf(m.content), ...attachmentsOf(m.content) ? { attachments: attachmentsOf(m.content) } : {} });
198
+ turns.push({ ...m.id ? { id: m.id } : {}, role: "user", text: textOf(m.content), ...attachmentsOf(m.content) ? { attachments: attachmentsOf(m.content) } : {} });
86
199
  } else if (m.role === "assistant") {
87
200
  const t = assistant();
201
+ if (!t.id && m.id) t.id = m.id;
202
+ const custom = m.custom;
203
+ if (Array.isArray(custom) && custom.length) t.custom = [...t.custom ?? [], ...custom.filter((c) => c && typeof c.name === "string")];
88
204
  const text = m.content ?? "";
89
205
  if (text) t.text = t.text ? `${t.text}
90
206
 
@@ -94,11 +210,11 @@ ${text}` : text;
94
210
  if (t.text.trim() && t.cuts?.[t.cuts.length - 1] !== t.text.length) t.cuts = [...t.cuts ?? [], t.text.length];
95
211
  }
96
212
  } else if (m.role === "tool") {
97
- const t = assistant();
213
+ const t = assistant(m.id);
98
214
  const failed = Boolean(m.error);
99
215
  t.tools = (t.tools ?? []).map((x) => x.id === m.toolCallId ? { ...x, status: failed ? "failed" : "completed" } : x);
100
216
  } else if (m.role === "reasoning") {
101
- const t = assistant();
217
+ const t = assistant(m.id);
102
218
  t.thought = (t.thought ?? "") + textOf(m.content);
103
219
  }
104
220
  }
@@ -106,27 +222,47 @@ ${text}` : text;
106
222
  }
107
223
  function applySnapshot(s, messages) {
108
224
  const fresh = messagesToTurns(messages);
109
- const old = s.turns;
225
+ let prefix = [];
226
+ if (s.olderCount && fresh[0]?.id) {
227
+ const at = s.turns.findIndex((t) => t.id === fresh[0].id);
228
+ if (at >= 0) prefix = s.turns.slice(0, at);
229
+ }
230
+ const keepOlder = prefix.length > 0;
231
+ const old = keepOlder ? s.turns.slice(prefix.length) : s.turns.slice(s.olderCount);
232
+ const lastIndex = fresh.length - 1;
110
233
  const turns = fresh.map((t, i) => {
111
234
  const o = old[i];
112
- if (!o || o.role !== t.role) return t;
235
+ const live = s.busy && i === lastIndex && t.role === "assistant";
236
+ const tools = t.tools?.map((x) => {
237
+ const prev = o?.role === t.role ? o.tools?.find((y) => y.id === x.id) : void 0;
238
+ const status = x.status === "pending" && (prev?.status ?? (live ? "in_progress" : void 0));
239
+ return { ...prev, ...x, ...status ? { status } : {} };
240
+ });
241
+ const base = tools ? { ...t, tools } : t;
242
+ if (!o || o.role !== t.role) return base;
113
243
  return {
114
- ...t,
244
+ ...base,
115
245
  ...o.seconds !== void 0 ? { seconds: o.seconds } : {},
116
246
  ...o.usage ? { usage: o.usage } : {},
117
247
  ...o.decisions ? { decisions: o.decisions } : {},
118
248
  ...o.thought && !t.thought ? { thought: o.thought } : {},
119
249
  ...o.attachments && !t.attachments ? { attachments: o.attachments } : {},
120
- ...o.tools && t.tools ? { tools: t.tools.map((x) => ({ ...o.tools.find((y) => y.id === x.id), ...x })) } : {}
250
+ ...o.custom && !t.custom ? { custom: o.custom } : {}
121
251
  };
122
252
  });
123
- return { ...s, turns };
253
+ return {
254
+ ...s,
255
+ turns: [...prefix, ...turns],
256
+ olderCount: prefix.length,
257
+ history: keepOlder ? s.history : s.historyNext ?? null,
258
+ historyNext: void 0
259
+ };
124
260
  }
125
261
  function reduceAgUiEvent(prev, e, now = Date.now()) {
126
262
  const s = prev.effects.length ? { ...prev, effects: [] } : prev;
127
263
  switch (e.type) {
128
264
  case "RUN_STARTED":
129
- return { ...s, busy: true, error: null, runId: e.runId ?? s.runId };
265
+ return { ...s, busy: true, error: null, step: null, runId: e.runId ?? s.runId };
130
266
  case "TEXT_MESSAGE_START":
131
267
  if (e.role && e.role !== "assistant") return s;
132
268
  return patchAssistant(s, (t) => ({ ...t, text: t.text && !t.text.endsWith("\n") ? `${t.text}
@@ -169,15 +305,35 @@ function reduceAgUiEvent(prev, e, now = Date.now()) {
169
305
  return finish(s, e, now);
170
306
  case "RUN_ERROR":
171
307
  return finish({ ...s, error: typeof e.message === "string" ? e.message : "El agente fall\xF3." }, e, now, true);
308
+ case "STEP_STARTED":
309
+ return typeof e.stepName === "string" ? { ...s, step: e.stepName } : s;
310
+ case "STEP_FINISHED":
311
+ return s.step === e.stepName ? { ...s, step: null } : s;
312
+ case "STATE_SNAPSHOT": {
313
+ const agentState = e.snapshot && typeof e.snapshot === "object" ? e.snapshot : {};
314
+ return { ...s, agentState, effects: [{ type: "state", state: agentState }] };
315
+ }
316
+ case "STATE_DELTA": {
317
+ if (!Array.isArray(e.delta)) return s;
318
+ const { doc, ok } = applyJsonPatch(s.agentState, e.delta);
319
+ return ok ? { ...s, agentState: doc, effects: [{ type: "state", state: doc }] } : s;
320
+ }
172
321
  case "CUSTOM":
173
322
  if (e.name === "ghosty.title") return { ...s, effects: [{ type: "title", title: e.value?.title }] };
174
- return s;
323
+ if (e.name === "ghosty.history") return { ...s, historyNext: toHistory(e.value) };
324
+ if (typeof e.name !== "string" || PROTOCOL_CUSTOM.has(e.name)) return s;
325
+ return patchAssistant(s, (t) => ({ ...t, custom: [...t.custom ?? [], { name: e.name, value: e.value, ...typeof e.timestamp === "number" ? { timestamp: e.timestamp } : {} }] }));
175
326
  default:
176
327
  return s;
177
328
  }
178
329
  }
330
+ function toHistory(v) {
331
+ if (!v || typeof v !== "object") return null;
332
+ const h = v;
333
+ return { hasMoreBefore: h.hasMoreBefore === true, ...typeof h.before === "string" ? { before: h.before } : {} };
334
+ }
179
335
  function finish(s0, e, now, failed = false) {
180
- let s = { ...s0, busy: false };
336
+ let s = { ...s0, busy: false, step: null };
181
337
  const outcome = e.outcome;
182
338
  const interrupted = outcome?.type === "interrupt" && Array.isArray(outcome.interrupts) && outcome.interrupts.length > 0;
183
339
  const clockOffset = typeof e.timestamp === "number" ? now - e.timestamp : 0;
@@ -214,11 +370,19 @@ function beginSend(prev, text, attachments = [], now = Date.now()) {
214
370
  const turns = text || attachments.length ? [...prev.turns, { role: "user", text, ...attachments.length ? { attachments } : {} }] : prev.turns;
215
371
  return { ...prev, turns, busy: true, error: null, notice: null, turnStartedAt: now, effects: [{ type: "turn-start" }] };
216
372
  }
373
+ function beginSteer(prev, text) {
374
+ return { ...prev, turns: [...prev.turns, { role: "user", text }], notice: null, effects: [] };
375
+ }
376
+ function prependHistory(prev, messages, page) {
377
+ const have = new Set(prev.turns.map((t) => t.id).filter(Boolean));
378
+ const older = messagesToTurns(messages).filter((t) => !t.id || !have.has(t.id));
379
+ return { ...prev, turns: [...older, ...prev.turns], olderCount: prev.olderCount + older.length, history: page, effects: [] };
380
+ }
217
381
  function sendFailed(prev, message) {
218
382
  return { ...prev, busy: false, turnStartedAt: 0, error: message, effects: [] };
219
383
  }
220
384
  function stopTurn(prev) {
221
- return { ...prev, busy: false, turnStartedAt: 0, turns: closeOpenTools(prev.turns, "cancelled"), effects: [] };
385
+ return { ...prev, busy: false, step: null, turnStartedAt: 0, turns: closeOpenTools(prev.turns, "cancelled"), effects: [] };
222
386
  }
223
387
  function dropLastExchange(prev) {
224
388
  const turns = [...prev.turns];
@@ -451,12 +615,15 @@ var TOOL_VERBS_EN = {
451
615
  };
452
616
 
453
617
  export {
618
+ applyJsonPatch,
454
619
  createPilotState,
455
620
  argsDetail,
456
621
  toPermission,
457
622
  messagesToTurns,
458
623
  reduceAgUiEvent,
459
624
  beginSend,
625
+ beginSteer,
626
+ prependHistory,
460
627
  sendFailed,
461
628
  stopTurn,
462
629
  dropLastExchange,
@@ -1,5 +1,6 @@
1
1
  export * from "./types";
2
- export { argsDetail, beginSend, createPilotState, dropLastExchange, expiryMs, lastUserTurn, messagesToTurns, reduceAgUiEvent, resolvePermission, sendFailed, stopTurn, toPermission, } from "./reducer";
2
+ export { argsDetail, beginSend, beginSteer, createPilotState, dropLastExchange, expiryMs, lastUserTurn, messagesToTurns, prependHistory, reduceAgUiEvent, resolvePermission, sendFailed, stopTurn, toPermission, } from "./reducer";
3
+ export { applyJsonPatch, type PatchOp } from "./jsonPatch";
3
4
  export { isNarration, narrationToMarkdown, splitNarration } from "./narration";
4
5
  export { cleanReplyOption, extractReplyOptions, type ReplyOptions } from "./options";
5
6
  export { TOOL_VERBS_EN, TOOL_VERBS_ES, groupSteps, isRunning, stepLabel, summarizeTools, toolName, toolNames, type ToolVerb, type ToolVerbs, } from "./steps";
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  TOOL_VERBS_EN,
3
3
  TOOL_VERBS_ES,
4
+ applyJsonPatch,
4
5
  argsDetail,
5
6
  beginSend,
7
+ beginSteer,
6
8
  cleanReplyOption,
7
9
  createPilotState,
8
10
  dropLastExchange,
@@ -14,6 +16,7 @@ import {
14
16
  lastUserTurn,
15
17
  messagesToTurns,
16
18
  narrationToMarkdown,
19
+ prependHistory,
17
20
  reduceAgUiEvent,
18
21
  resolvePermission,
19
22
  sendFailed,
@@ -24,7 +27,7 @@ import {
24
27
  toPermission,
25
28
  toolName,
26
29
  toolNames
27
- } from "../chunks/chunk-JQEB37IY.js";
30
+ } from "../chunks/chunk-VK54J63C.js";
28
31
  import {
29
32
  applyReferences,
30
33
  cleanUserText,
@@ -34,9 +37,11 @@ import {
34
37
  export {
35
38
  TOOL_VERBS_EN,
36
39
  TOOL_VERBS_ES,
40
+ applyJsonPatch,
37
41
  applyReferences,
38
42
  argsDetail,
39
43
  beginSend,
44
+ beginSteer,
40
45
  cleanReplyOption,
41
46
  cleanUserText,
42
47
  createPilotState,
@@ -50,6 +55,7 @@ export {
50
55
  lastUserTurn,
51
56
  messagesToTurns,
52
57
  narrationToMarkdown,
58
+ prependHistory,
53
59
  reduceAgUiEvent,
54
60
  resolvePermission,
55
61
  sendFailed,
@@ -0,0 +1,11 @@
1
+ export interface PatchOp {
2
+ op: "add" | "remove" | "replace" | "move" | "copy" | "test";
3
+ path: string;
4
+ value?: unknown;
5
+ from?: string;
6
+ }
7
+ /** Aplica `ops` en orden. Si una falla, regresa el documento original y `ok: false`. */
8
+ export declare function applyJsonPatch<T>(doc: T, ops: PatchOp[]): {
9
+ doc: T;
10
+ ok: boolean;
11
+ };
@@ -1,5 +1,5 @@
1
1
  import type { Interrupt, Message } from "@ag-ui/core";
2
- import type { Attachment, Decision, PermissionRequest, PilotState, Turn } from "./types";
2
+ import type { Attachment, Decision, HistoryPage, PermissionRequest, PilotState, Turn } from "./types";
3
3
  export declare function createPilotState(turns?: Turn[]): PilotState;
4
4
  type Ev = {
5
5
  type: string;
@@ -18,6 +18,16 @@ export declare function messagesToTurns(messages: Message[]): Turn[];
18
18
  export declare function reduceAgUiEvent(prev: PilotState, e: Ev, now?: number): PilotState;
19
19
  /** El usuario manda un mensaje (o contesta una tarjeta: `text` vacío, sin burbuja). */
20
20
  export declare function beginSend(prev: PilotState, text: string, attachments?: Attachment[], now?: number): PilotState;
21
+ /**
22
+ * Steer: un mensaje del usuario mientras el run sigue. Su burbuja entra en el hilo y lo que el agente
23
+ * diga después va en una respuesta nueva debajo; el run (y su reloj) siguen siendo los mismos.
24
+ */
25
+ export declare function beginSteer(prev: PilotState, text: string): PilotState;
26
+ /**
27
+ * Mensajes más viejos (`loadOlder`) delante del hilo. Las páginas empiezan en un mensaje del usuario;
28
+ * lo que ya se tenía (mismo id) no se repite.
29
+ */
30
+ export declare function prependHistory(prev: PilotState, messages: Message[], page: HistoryPage | null): PilotState;
21
31
  /** El servidor no aceptó el encargo (HTTP): se DICE, para que no se quede pensando. */
22
32
  export declare function sendFailed(prev: PilotState, message: string): PilotState;
23
33
  /** Detener: el cliente cortó la conexión (el servidor corta el run). */
@@ -38,7 +38,21 @@ export interface Decision {
38
38
  status: "approved" | "rejected" | "expired" | "cancelled";
39
39
  detail?: string;
40
40
  }
41
+ /** Un evento `CUSTOM` del turno (entregas de archivos, avisos propios de la app). */
42
+ export interface CustomEntry {
43
+ name: string;
44
+ value: unknown;
45
+ timestamp?: number;
46
+ }
47
+ /** Hay mensajes más viejos que los del snapshot (`CUSTOM ghosty.history`). */
48
+ export interface HistoryPage {
49
+ hasMoreBefore: boolean;
50
+ /** Cursor opaco del servidor: se le regresa a `loadOlder`. */
51
+ before?: string;
52
+ }
41
53
  export interface Turn {
54
+ /** El id del primer mensaje AG-UI del turno (sólo los que vienen de un snapshot). */
55
+ id?: string;
42
56
  role: "user" | "assistant";
43
57
  text: string;
44
58
  /** Cuánto tardó, en segundos (sólo los turnos vistos en vivo). */
@@ -51,6 +65,9 @@ export interface Turn {
51
65
  usage?: Usage;
52
66
  /** Lo que el usuario decidió en las tarjetas de este turno (sólo en vivo). */
53
67
  decisions?: Decision[];
68
+ /** Los `CUSTOM` del turno (p. ej. `ghosty.delivery`), para `renderDeliveries`. Del snapshot salen
69
+ * del campo `custom` de cada mensaje del asistente. */
70
+ custom?: CustomEntry[];
54
71
  }
55
72
  /** Una opción de la tarjeta de aprobación. */
56
73
  export interface PermissionOption {
@@ -90,6 +107,9 @@ export type PilotEffect = {
90
107
  } | {
91
108
  type: "title";
92
109
  title?: string;
110
+ } | {
111
+ type: "state";
112
+ state: Record<string, unknown>;
93
113
  };
94
114
  export interface PilotState {
95
115
  turns: Turn[];
@@ -103,6 +123,16 @@ export interface PilotState {
103
123
  /** Cuándo empezó el turno que ESTE cliente encargó (0 = ninguno). */
104
124
  turnStartedAt: number;
105
125
  runId: string | null;
126
+ /** El estado compartido del agente (`STATE_SNAPSHOT`, con `STATE_DELTA` aplicado encima). */
127
+ agentState: Record<string, unknown>;
128
+ /** El paso con nombre en curso (`STEP_STARTED`), p. ej. `waking`. */
129
+ step: string | null;
130
+ /** Si hay mensajes más viejos por cargar (`loadOlder`). */
131
+ history: HistoryPage | null;
132
+ /** Cuántos de `turns` llegaron con `loadOlder` (van antes del snapshot). */
133
+ olderCount: number;
134
+ /** El `ghosty.history` que acompaña al snapshot que viene. */
135
+ historyNext?: HistoryPage | null;
106
136
  /** Efectos de la ÚLTIMA transición. El hook los ejecuta y los descarta. */
107
137
  effects: PilotEffect[];
108
138
  }
@@ -19,6 +19,8 @@ export interface PilotoFeatures {
19
19
  replyOptions?: boolean;
20
20
  /** «· 12 s» junto a la frase de espera. Default: sí. */
21
21
  duration?: boolean;
22
+ /** Mandar con el run vivo (steer). Default: lo que diga el agente en `state.caps.steer`. */
23
+ steer?: boolean;
22
24
  }
23
25
  export interface PilotoSlots {
24
26
  /** Lo que la app pinte bajo cada respuesta (archivos, tarjetas propias). */
@@ -31,6 +33,8 @@ export interface PilotoSlots {
31
33
  headerActions?: ReactNode;
32
34
  /** Pegado arriba del composer (un aviso, un banner). */
33
35
  aboveComposer?: ReactNode;
36
+ /** Un panel junto al chat (subagentes, vista previa), con el estado del agente. */
37
+ sidePanel?: (agentState: Record<string, unknown>) => ReactNode;
34
38
  }
35
39
  export interface PilotoProps extends UsePilotOptions {
36
40
  transport: PilotTransport | null;
@@ -1,6 +1,8 @@
1
1
  /** Los tres puntitos de «escribiendo» (color y animación en CSS: `gc-typing`). */
2
2
  export declare function TypingDots(): import("react").JSX.Element;
3
- export declare function WaitLabel({ since, showDuration, }: {
3
+ export declare function WaitLabel({ since, showDuration, step, }: {
4
+ /** El paso con nombre en curso (`STEP_STARTED`): si `strings.steps` lo nombra, esa es la frase. */
5
+ step?: string | null;
4
6
  /** Desde que se MANDÓ el mensaje (no desde que se montó la etiqueta). */
5
7
  since?: number | null;
6
8
  showDuration?: boolean;
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  TOOL_VERBS_EN,
3
3
  TOOL_VERBS_ES,
4
+ applyJsonPatch,
4
5
  argsDetail,
5
6
  beginSend,
7
+ beginSteer,
6
8
  cleanReplyOption,
7
9
  createPilotState,
8
10
  dropLastExchange,
@@ -14,6 +16,7 @@ import {
14
16
  lastUserTurn,
15
17
  messagesToTurns,
16
18
  narrationToMarkdown,
19
+ prependHistory,
17
20
  reduceAgUiEvent,
18
21
  resolvePermission,
19
22
  sendFailed,
@@ -24,7 +27,7 @@ import {
24
27
  toPermission,
25
28
  toolName,
26
29
  toolNames
27
- } from "../chunks/chunk-JQEB37IY.js";
30
+ } from "../chunks/chunk-VK54J63C.js";
28
31
  import {
29
32
  PilotHttpError,
30
33
  createAgUiTransport,
@@ -884,6 +887,9 @@ var STRINGS_ES = {
884
887
  linkOpen: "Abrir",
885
888
  close: "Cerrar",
886
889
  goToLatest: "Ir al \xFAltimo mensaje",
890
+ steps: { waking: "Despertando al agente\u2026", session: "Preparando la sesi\xF3n\u2026" },
891
+ loadOlder: "Cargar mensajes anteriores",
892
+ loadingOlder: "Cargando\u2026",
887
893
  confirmTitle: "Necesito tu permiso para",
888
894
  approve: "Aprobar",
889
895
  reject: "Rechazar",
@@ -949,6 +955,9 @@ var STRINGS_EN = {
949
955
  linkOpen: "Open",
950
956
  close: "Close",
951
957
  goToLatest: "Go to latest message",
958
+ steps: { waking: "Waking the agent\u2026", session: "Starting the session\u2026" },
959
+ loadOlder: "Load earlier messages",
960
+ loadingOlder: "Loading\u2026",
952
961
  confirmTitle: "I need your permission to",
953
962
  approve: "Approve",
954
963
  reject: "Reject",
@@ -2251,7 +2260,8 @@ function TypingDots() {
2251
2260
  }
2252
2261
  function WaitLabel({
2253
2262
  since,
2254
- showDuration = true
2263
+ showDuration = true,
2264
+ step
2255
2265
  }) {
2256
2266
  const { strings: t } = usePiloto();
2257
2267
  const [mounted] = useState7(() => Date.now());
@@ -2262,7 +2272,8 @@ function WaitLabel({
2262
2272
  }, []);
2263
2273
  const seconds = Math.max(0, Math.floor((now - (since || mounted)) / 1e3));
2264
2274
  const phrases = t.thinkingPhrases;
2265
- const what = phrases?.length ? seconds >= STILL_WORKING_S ? t.stillWorking : phrases[Math.floor(seconds / 4) % phrases.length] : t.howItGoes(seconds);
2275
+ const stepText = step ? t.steps?.[step] : void 0;
2276
+ const what = stepText ? stepText : phrases?.length ? seconds >= STILL_WORKING_S ? t.stillWorking : phrases[Math.floor(seconds / 4) % phrases.length] : t.howItGoes(seconds);
2266
2277
  return /* @__PURE__ */ jsx9("div", { className: "flex flex-col gap-0.5 text-sm text-text-secondary", children: /* @__PURE__ */ jsxs7("p", { className: "flex items-center", children: [
2267
2278
  /* @__PURE__ */ jsx9(AnimatePresence2, { mode: "wait", initial: false, children: /* @__PURE__ */ jsx9(
2268
2279
  motion2.span,
@@ -2329,6 +2340,7 @@ function usePilot(transport, opts = {}) {
2329
2340
  if (e.type === "turn-start") o.onTurnStart?.();
2330
2341
  else if (e.type === "turn-done") o.onTurnDone?.({ text: e.text, runId: e.runId, seconds: e.seconds, error: e.error, interrupted: e.interrupted });
2331
2342
  else if (e.type === "title") o.onTitle?.(e.title);
2343
+ else if (e.type === "state") o.onState?.(e.state);
2332
2344
  }
2333
2345
  }, []);
2334
2346
  const runNow = useCallback2(
@@ -2348,7 +2360,8 @@ function usePilot(transport, opts = {}) {
2348
2360
  ctrl.current?.abort();
2349
2361
  const c = new AbortController();
2350
2362
  ctrl.current = c;
2351
- if (input.messages.length || input.resume?.length) liveRun.current = input.runId;
2363
+ const steering = typeof input.forwardedProps?.steer === "string";
2364
+ if (!steering && (input.messages.length || input.resume?.length)) liveRun.current = input.runId;
2352
2365
  optsRef.current.onSend?.(input);
2353
2366
  let ended = false;
2354
2367
  try {
@@ -2372,6 +2385,10 @@ function usePilot(transport, opts = {}) {
2372
2385
  commit({ ...ref.current, notice: err instanceof Error ? err.message : null, effects: [] });
2373
2386
  return runNow({ messages: [] }, true);
2374
2387
  }
2388
+ if (steering && !reopening) {
2389
+ commit({ ...ref.current, notice: err instanceof Error && err.message ? err.message : null, effects: [] });
2390
+ return runNow({ messages: [] }, true);
2391
+ }
2375
2392
  commit(sendFailed(ref.current, err instanceof Error && err.message ? err.message : "No se pudo hablar con el agente."));
2376
2393
  return;
2377
2394
  }
@@ -2390,9 +2407,17 @@ function usePilot(transport, opts = {}) {
2390
2407
  ...ref.current.permissions.filter((p) => !answers.current.has(p.id)).map((p) => ({ interruptId: p.id, status: "cancelled" })),
2391
2408
  ...[...answers.current.entries()].map(([interruptId, a]) => ({ interruptId, ...a }))
2392
2409
  ];
2410
+ const steerOn = () => optsRef.current.steer ?? ref.current.agentState.caps?.steer === true;
2393
2411
  const send = useCallback2(
2394
2412
  async (text, attachments = [], _extra = {}) => {
2395
- if (!transportRef.current || ref.current.busy) return;
2413
+ if (!transportRef.current) return;
2414
+ if (ref.current.busy) {
2415
+ const live = liveRun.current ?? ref.current.runId;
2416
+ if (!steerOn() || !live || !text.trim() || attachments.length) return;
2417
+ commit(beginSteer(ref.current, text));
2418
+ await runNow({ messages: [userMessage(text, [])], forwardedProps: { steer: live } });
2419
+ return;
2420
+ }
2396
2421
  const resume = pendingResume();
2397
2422
  answers.current.clear();
2398
2423
  let s = ref.current;
@@ -2454,7 +2479,27 @@ function usePilot(transport, opts = {}) {
2454
2479
  },
2455
2480
  [commit, sendAnswersIfComplete]
2456
2481
  );
2457
- return { state, threadId, send, stop, retry, respondPermission, recordDecision, expirePermission };
2482
+ const [loadingOlder, setLoadingOlder] = useState8(false);
2483
+ const loadingRef = useRef4(false);
2484
+ const loadOlder = useCallback2(async () => {
2485
+ const fn = optsRef.current.loadOlder;
2486
+ const h = ref.current.history;
2487
+ if (!fn || !h?.hasMoreBefore || loadingRef.current) return;
2488
+ loadingRef.current = true;
2489
+ setLoadingOlder(true);
2490
+ try {
2491
+ const page = await fn(h.before);
2492
+ if (ref.current.history !== h) return;
2493
+ commit(prependHistory(ref.current, page.messages ?? [], { hasMoreBefore: page.hasMoreBefore === true, ...page.before ? { before: page.before } : {} }));
2494
+ } catch (err) {
2495
+ commit({ ...ref.current, notice: err instanceof Error && err.message ? err.message : null, effects: [] });
2496
+ } finally {
2497
+ loadingRef.current = false;
2498
+ setLoadingOlder(false);
2499
+ }
2500
+ }, [commit]);
2501
+ const canSteer = Boolean(transport) && (opts.steer ?? state.agentState.caps?.steer === true);
2502
+ return { state, threadId, send, stop, retry, respondPermission, recordDecision, expirePermission, canSteer, loadOlder, loadingOlder };
2458
2503
  }
2459
2504
 
2460
2505
  // src/react/Piloto.tsx
@@ -2463,10 +2508,10 @@ var useIsoLayoutEffect = typeof window === "undefined" ? useEffect6 : useLayoutE
2463
2508
  var ANCHOR_GAP = 24;
2464
2509
  var Piloto = forwardRef3(function Piloto2(props, handle) {
2465
2510
  const { transport, pilot: external, locale = "es", theme = "light", features = {}, slots = {} } = props;
2466
- const own = usePilot(external ? null : transport, props);
2511
+ const own = usePilot(external ? null : transport, features.steer === void 0 ? props : { ...props, steer: features.steer });
2467
2512
  const pilot = external ?? own;
2468
2513
  const { state } = pilot;
2469
- const { turns, busy, error, notice, permissions } = state;
2514
+ const { turns, busy, error, notice, permissions, olderCount } = state;
2470
2515
  const strings = useMemo3(() => ({ ...locale === "en" ? STRINGS_EN : STRINGS_ES, ...props.strings }), [locale, props.strings]);
2471
2516
  const config = useMemo3(
2472
2517
  () => ({
@@ -2550,6 +2595,18 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2550
2595
  const touchY = useRef5(null);
2551
2596
  const [atBottom, setAtBottom] = useState9(true);
2552
2597
  const message = (n) => scroller.current?.querySelector(`[data-turn="${n}"]`) ?? null;
2598
+ const olderSeen = useRef5(olderCount);
2599
+ const heightBefore = useRef5(0);
2600
+ useIsoLayoutEffect(() => {
2601
+ const el = scroller.current;
2602
+ if (el && olderCount > olderSeen.current && heightBefore.current) el.scrollTop += el.scrollHeight - heightBefore.current;
2603
+ olderSeen.current = olderCount;
2604
+ heightBefore.current = 0;
2605
+ }, [olderCount]);
2606
+ const loadOlder = () => {
2607
+ heightBefore.current = scroller.current?.scrollHeight ?? 0;
2608
+ void pilot.loadOlder();
2609
+ };
2553
2610
  const placed = useRef5(false);
2554
2611
  useEffect6(() => {
2555
2612
  if (placed.current || turns.length === 0) return;
@@ -2571,10 +2628,10 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2571
2628
  useEffect6(() => {
2572
2629
  if (mine === mineSeen.current) return;
2573
2630
  mineSeen.current = mine;
2574
- if (mine) setAnchor(mineIndex);
2631
+ if (mine) setAnchor(mineIndex - olderCount);
2575
2632
  following.current = true;
2576
2633
  }, [mine, mineIndex]);
2577
- const gapAbove = (a) => anchor === 0 ? a.offsetTop : ANCHOR_GAP;
2634
+ const gapAbove = (a) => anchor === -olderCount ? a.offsetTop : ANCHOR_GAP;
2578
2635
  const measureSpacer = useCallback3(() => {
2579
2636
  const sc = scroller.current;
2580
2637
  const sp = spacer.current;
@@ -2636,7 +2693,7 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2636
2693
  onSubmit: send,
2637
2694
  onStop: pilot.stop,
2638
2695
  busy,
2639
- canSteer: false,
2696
+ canSteer: pilot.canSteer,
2640
2697
  placeholder,
2641
2698
  attachments: showFiles,
2642
2699
  upload: props.upload,
@@ -2650,8 +2707,12 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2650
2707
  draft.n
2651
2708
  )
2652
2709
  ] });
2710
+ const withPanel = (chat) => slots.sidePanel ? /* @__PURE__ */ jsxs8("div", { className: "flex min-h-0 flex-1 flex-row", children: [
2711
+ /* @__PURE__ */ jsx10("div", { className: "relative flex min-h-0 min-w-0 flex-1 flex-col", children: chat }),
2712
+ slots.sidePanel(state.agentState)
2713
+ ] }) : chat;
2653
2714
  const permissionCards = cards.map((p) => /* @__PURE__ */ jsx10(ConfirmCard, { request: p, onRespond: respond, onSettled: settle, onExpire: pilot.expirePermission }, p.id));
2654
- return /* @__PURE__ */ jsx10(PilotoContext.Provider, { value: config, children: /* @__PURE__ */ jsx10("div", { ...rootProps(theme, props.skin), "data-busy": busy || void 0, className: `piloto ${props.className ?? ""}`, style: props.style, children: empty ? (
2715
+ return /* @__PURE__ */ jsx10(PilotoContext.Provider, { value: config, children: /* @__PURE__ */ jsx10("div", { ...rootProps(theme, props.skin), "data-busy": busy || void 0, className: `piloto ${props.className ?? ""}`, style: props.style, children: withPanel(empty ? (
2655
2716
  // Un hilo vacío enseña lo mismo que la portada: la cara, el saludo, la caja y por dónde
2656
2717
  // empezar. Si no cabe (panel bajo), se desplaza: `safe center` nunca empuja nada fuera.
2657
2718
  /* @__PURE__ */ jsxs8("div", { className: "gs-scroll flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-4 pb-[10vh] [justify-content:safe_center]", children: [
@@ -2692,10 +2753,20 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2692
2753
  className: "gs-scroll relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
2693
2754
  children: [
2694
2755
  /* @__PURE__ */ jsxs8("div", { ref: column, className: "mx-auto flex w-full max-w-[784px] flex-col gap-6 px-4 py-8 sm:px-6", children: [
2756
+ state.history?.hasMoreBefore && (external || props.loadOlder) && /* @__PURE__ */ jsx10(
2757
+ "button",
2758
+ {
2759
+ type: "button",
2760
+ onClick: loadOlder,
2761
+ disabled: pilot.loadingOlder,
2762
+ className: "self-center rounded-full border border-border-primary px-3 py-1 text-sm text-text-secondary transition hover:text-text-primary disabled:opacity-60",
2763
+ children: pilot.loadingOlder ? t.loadingOlder : t.loadOlder
2764
+ }
2765
+ ),
2695
2766
  turns.map((turn, i) => {
2696
2767
  const isLast = i === turns.length - 1;
2697
2768
  const live = busy && isLast;
2698
- return /* @__PURE__ */ jsx10("div", { "data-turn": i, children: turn.role === "user" ? /* @__PURE__ */ jsx10(UserBubble, { turn }) : /* @__PURE__ */ jsx10(
2769
+ return /* @__PURE__ */ jsx10("div", { "data-turn": i - olderCount, children: turn.role === "user" ? /* @__PURE__ */ jsx10(UserBubble, { turn }) : /* @__PURE__ */ jsx10(
2699
2770
  AgentBubble,
2700
2771
  {
2701
2772
  turn,
@@ -2707,12 +2778,12 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2707
2778
  showDuration: features.duration !== false,
2708
2779
  renderDeliveries: slots.renderDeliveries
2709
2780
  }
2710
- ) }, i);
2781
+ ) }, i - olderCount);
2711
2782
  }),
2712
2783
  permissionCards,
2713
2784
  busy && !toolRunning && !cards.length && /* @__PURE__ */ jsxs8("div", { className: "flex flex-col gap-1.5", children: [
2714
2785
  /* @__PURE__ */ jsx10(TypingDots, {}),
2715
- /* @__PURE__ */ jsx10(WaitLabel, { since: state.turnStartedAt, showDuration: features.duration !== false })
2786
+ /* @__PURE__ */ jsx10(WaitLabel, { since: state.turnStartedAt, step: state.step, showDuration: features.duration !== false })
2716
2787
  ] }),
2717
2788
  notice && /* @__PURE__ */ jsx10("p", { className: "text-sm text-text-tertiary", children: notice }),
2718
2789
  error && /* @__PURE__ */ jsx10("p", { className: "text-sm text-text-danger", children: error })
@@ -2735,7 +2806,7 @@ var Piloto = forwardRef3(function Piloto2(props, handle) {
2735
2806
  slots.aboveComposer,
2736
2807
  composer
2737
2808
  ] })
2738
- ] }) }) });
2809
+ ] })) }) });
2739
2810
  });
2740
2811
  export {
2741
2812
  AgentBubble,
@@ -2769,9 +2840,11 @@ export {
2769
2840
  UsageStats,
2770
2841
  UserBubble,
2771
2842
  WaitLabel,
2843
+ applyJsonPatch,
2772
2844
  applyReferences,
2773
2845
  argsDetail,
2774
2846
  beginSend,
2847
+ beginSteer,
2775
2848
  cleanReplyOption,
2776
2849
  cleanUserText,
2777
2850
  createAgUiTransport,
@@ -2788,6 +2861,7 @@ export {
2788
2861
  messagesToTurns,
2789
2862
  mimeOf,
2790
2863
  narrationToMarkdown,
2864
+ prependHistory,
2791
2865
  readSse,
2792
2866
  reduceAgUiEvent,
2793
2867
  renderInline,
@@ -54,6 +54,10 @@ export interface PilotStrings {
54
54
  linkOpen: string;
55
55
  close: string;
56
56
  goToLatest: string;
57
+ /** La frase de espera de cada paso con nombre (`STEP_STARTED.stepName`). Los que no estén aquí no se pintan. */
58
+ steps: Record<string, string>;
59
+ loadOlder: string;
60
+ loadingOlder: string;
57
61
  confirmTitle: string;
58
62
  approve: string;
59
63
  reject: string;
@@ -1,5 +1,5 @@
1
- import type { BaseEvent, RunAgentInput } from "@ag-ui/core";
2
- import type { Attachment, Decision, PermissionRequest, PilotState, Reference, Turn } from "../core/types";
1
+ import type { BaseEvent, Message, RunAgentInput } from "@ag-ui/core";
2
+ import type { Attachment, Decision, HistoryPage, PermissionRequest, PilotState, Reference, Turn } from "../core/types";
3
3
  import { type PilotTransport } from "../transport";
4
4
  export interface TurnDoneInfo {
5
5
  /** El texto final de la respuesta. */
@@ -31,6 +31,20 @@ export interface UsePilotOptions {
31
31
  onEvent?: (event: BaseEvent) => void;
32
32
  /** Cada run que sale (analítica). */
33
33
  onSend?: (input: RunAgentInput) => void;
34
+ /** El estado del agente cambió (`STATE_SNAPSHOT` / `STATE_DELTA` ya aplicado). */
35
+ onState?: (state: Record<string, unknown>) => void;
36
+ /**
37
+ * Steer: mandar con el run vivo (docs/protocol.md §6). Default: lo que diga el agente en
38
+ * `state.caps.steer`. Sin steer, mandar mientras corre no hace nada.
39
+ */
40
+ steer?: boolean;
41
+ /**
42
+ * Trae los mensajes anteriores a `before` (el cursor de `CUSTOM ghosty.history`), p. ej. del REST de
43
+ * lectura de la app. Las páginas empiezan en un mensaje del usuario.
44
+ */
45
+ loadOlder?: (before: string | undefined) => Promise<{
46
+ messages: Message[];
47
+ } & HistoryPage>;
34
48
  }
35
49
  export interface SendOptions {
36
50
  references?: Reference[];
@@ -49,6 +63,11 @@ export interface Pilot {
49
63
  recordDecision: (id: string, status: Decision["status"], asked?: PermissionRequest) => void;
50
64
  /** Una tarjeta venció sin contestarse: se anota y su interrupt viaja como `cancelled` en el siguiente run. */
51
65
  expirePermission: (id: string) => void;
66
+ /** Se puede mandar con el run vivo (steer). */
67
+ canSteer: boolean;
68
+ /** Antepone la página anterior del hilo (si hay `loadOlder` y `state.history.hasMoreBefore`). */
69
+ loadOlder: () => Promise<void>;
70
+ loadingOlder: boolean;
52
71
  }
53
72
  /** El contexto de la app como `context` de AG-UI: `[{description, value}]`, `value` siempre texto. */
54
73
  export declare function toAgUiContext(ctx: unknown): RunAgentInput["context"];
package/dist/styles.css CHANGED
@@ -511,6 +511,9 @@
511
511
  .piloto .flex-col {
512
512
  flex-direction: column;
513
513
  }
514
+ .piloto .flex-row {
515
+ flex-direction: row;
516
+ }
514
517
  .piloto .flex-wrap {
515
518
  flex-wrap: wrap;
516
519
  }
@@ -588,6 +591,9 @@
588
591
  .piloto :where(.divide-border > :not(:last-child)) {
589
592
  border-color: var(--color-border);
590
593
  }
594
+ .piloto .self-center {
595
+ align-self: center;
596
+ }
591
597
  .piloto .truncate {
592
598
  overflow: hidden;
593
599
  text-overflow: ellipsis;
@@ -1404,6 +1410,9 @@
1404
1410
  .piloto .disabled\:opacity-50:disabled {
1405
1411
  opacity: 50%;
1406
1412
  }
1413
+ .piloto .disabled\:opacity-60:disabled {
1414
+ opacity: 60%;
1415
+ }
1407
1416
  @supports (backdrop-filter: var(--tw)) {
1408
1417
  .piloto .supports-\[backdrop-filter\]\:bg-background\/70 {
1409
1418
  background-color: var(--color-background);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghostykit",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Ghosty Autopilot \u2014 the autopilot chat for your React app, on AG-UI: works with LangGraph, Mastra, CrewAI or any AG-UI agent. Streaming text, agent steps, human approval cards (interrupt/resume), attachments and voice.",
5
5
  "type": "module",
6
6
  "main": "./dist/core/index.js",