ghostykit 0.7.0 → 0.7.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.
package/README.md CHANGED
@@ -106,7 +106,8 @@ ref.current?.send("Why did this fail?"); // ask on the user'
106
106
  ```
107
107
 
108
108
  Other props: `features` (`attachments`, `retry`, `copy`, `usage`, `replyOptions`, `duration`,
109
- `steer`), `references` / `onReferencesChange`, `placeholder`, `avatar`, `initialTurns`, `loadOlder`
109
+ `steer`, `narration`: `false` keeps the text written before each tool as part of the answer instead of
110
+ checked progress lines), `references` / `onReferencesChange`, `placeholder`, `avatar`, `initialTurns`, `loadOlder`
110
111
  (earlier pages of a long thread), `forwardedProps` (extra keys for every run, e.g.
111
112
  `{permissions: "ask"}`), and the callbacks `onTurnStart`, `onTitle`, `onState`, `onEvent` (every raw
112
113
  AG-UI event), `onSend`. The agent's shared state (`STATE_SNAPSHOT` + `STATE_DELTA`) is
@@ -152,6 +153,7 @@ const PLUGINS = [crm, modelPicker({ onChange: (model) => api.setModel(model) }),
152
153
  | `strings` | Texts over the locale's. | `{ stop: "Para" }` |
153
154
  | `markdown.transform` | Rewrites the answer's markdown, with the turn at hand (chained). | `(md, turn) => md.replace(/#(\d+)/g, "[#$1](#folio-$1)")` |
154
155
  | `markdown.onLinkClick` | A click on a link; `true` if handled (asked in order). | open in-app |
156
+ | `markdown.components` | How a markdown element is drawn, by tag (gets its props and `ctx`): e.g. a link that is a CTA as a button. | `{ a: ({ href, children, ctx }) => … }` |
155
157
  | `markdown.blocks` | Fenced blocks by name (```` ```eb-file ````) taken out of the text and drawn as cards under the answer; one still being written comes with `complete: false`. | `{ "eb-file": FileCard }` |
156
158
  | `vars`, `className` | Branding on the root: `--piloto-*` tokens and classes. | `{ "--piloto-radius-bubble": "6px" }` |
157
159
  | `tools.verbs` | How each tool is named in the steps. | `{ run_cron: { one, many, running } }` |
@@ -163,17 +165,21 @@ const PLUGINS = [crm, modelPicker({ onChange: (model) => api.setModel(model) }),
163
165
  | `message.assistantBefore` | Before each answer's text, under its steps. | live page progress, "Creating image…" |
164
166
  | `message.user` / `message.assistant` | Extras under each user message / answer. | `({ turn, live }) => …` |
165
167
  | `message.formatUserText` | How the user's text is shown (chained). | strip app plumbing |
168
+ | `message.beforeSend` | Rewrites the text that goes out (chained). | prepend what was pointed at |
169
+ | `message.avatar` | The agent's avatar next to each answer. | `() => <img src={logo} />` |
166
170
  | `composer.actions` | Buttons in the composer bar, between the image button and the clip. | "My files", "Point" |
167
171
  | `composer.actionsEnd` | Buttons after the clip. | model picker |
168
172
  | `composer.above` | Above the message box. | `<Subagents pilot={ctx.pilot} />` |
169
173
  | `composer.below` | Under the message box. | suggestion chips |
170
- | `composer.attachments` | `upload`, `transcribe`, `voiceNoteText`. | `{ upload: async (f) => ({ url }) }` |
174
+ | `composer.attachments` | `upload`, `transcribe`, `voiceNoteText`, and the limits `accept`, `maxFiles`, `maxBytes`. A voice note keeps its audio attached when there is `upload`. | `{ upload, accept: "image/*", maxFiles: 4, maxBytes: 4_000_000 }` |
175
+ | `composer.replace` | `{ when(ctx), view }`: drawn instead of the message box while `when` is true. | trial over (403 + `x-needs-login`) → "Create your account" |
171
176
  | `header` | Top right, over the thread. | share button |
172
177
  | `sidePanel` | Next to the chat. | live page, subagents |
173
178
  | `aboveThread` | Above the messages. | a banner |
174
179
  | `empty` | The empty thread (instead of the greeting). | onboarding |
175
180
  | `waiting` | While the agent hasn't answered (instead of the waiting phrase). Gets `step`, `since`. | "Creating your image…" |
176
181
  | `wrap` | A provider around the whole chat (`{ctx, children}`); the first plugin is outermost. | artifacts panel |
182
+ | `onHttpError` | The server refused a message before answering (`{status, code, message, headers}`; also in `pilot.state.httpError` until the next send). | 429 → upgrade banner |
177
183
  | `onTurnStart` / `onTurnDone` | This client started / finished a turn. | open the last delivery |
178
184
 
179
185
  Every view gets `ctx`: `{pilot, agentState, strings, send, addReference, addAttachments, setDraft, focus, plugins}`.
@@ -186,9 +192,9 @@ in the URL).
186
192
 
187
193
  **Order and precedence**, in the order of the `plugins` list:
188
194
 
189
- - **Maps** (`strings`, `tools.verbs`, `tools.icons`, `vars`) are merged, and later plugins win. `markdown` and `formatUserText` are chained.
195
+ - **Maps** (`strings`, `tools.verbs`, `tools.icons`, `vars`) are merged, and later plugins win. `markdown.transform`, `formatUserText` and `beforeSend` are chained.
190
196
  - **`events` and `state`** call every handler, in order.
191
- - **Keyed or single views** (`tools.render`, `message.custom`, `markdown.blocks`, `empty`, `waiting`, `composer.attachments`): the first plugin that declares one wins. A clash is a development warning.
197
+ - **Keyed or single views** (`tools.render`, `message.custom`, `markdown.blocks`, `markdown.components`, `message.avatar`, `empty`, `waiting`, `composer.attachments`, `composer.replace`): the first plugin that declares one wins. A clash is a development warning.
192
198
  - **Lists** (`header`, `sidePanel`, `aboveThread`, `composer.actions`/`actionsEnd`/`above`/`below`, `message.user`/`assistant`/`assistantBefore`, `wrap`, `onTurnStart`/`onTurnDone`) all apply, in order.
193
199
 
194
200
  In development, ghostykit also warns once about a `CUSTOM` event that nothing handles.
@@ -439,7 +439,7 @@ function finish(s0, e, now, failed = false) {
439
439
  }
440
440
  function beginSend(prev, text, attachments = [], now = Date.now()) {
441
441
  const turns = text || attachments.length ? [...prev.turns, { role: "user", text, ...attachments.length ? { attachments } : {} }] : prev.turns;
442
- return { ...prev, turns, busy: true, error: null, notice: null, cutRun: null, turnStartedAt: now, effects: [{ type: "turn-start" }] };
442
+ return { ...prev, turns, busy: true, error: null, notice: null, cutRun: null, httpError: null, turnStartedAt: now, effects: [{ type: "turn-start" }] };
443
443
  }
444
444
  function beginSteer(prev, text) {
445
445
  return { ...prev, turns: [...prev.turns, { role: "user", text }], notice: null, openText: null, effects: [] };
@@ -1,11 +1,12 @@
1
1
  // src/transport/index.ts
2
2
  var PilotHttpError = class extends Error {
3
- constructor(message, status, code, retryAfter) {
3
+ constructor(message, status, code, retryAfter, headers) {
4
4
  super(message);
5
5
  this.name = "PilotHttpError";
6
6
  this.status = status;
7
7
  this.code = code;
8
8
  this.retryAfter = retryAfter;
9
+ this.headers = headers;
9
10
  }
10
11
  };
11
12
  async function readSse(body, onMessage) {
@@ -62,7 +63,9 @@ function createAgUiTransport(opts) {
62
63
  }
63
64
  if (!res.ok) {
64
65
  const json = await res.json().catch(() => null);
65
- throw new PilotHttpError(json?.message ?? json?.error ?? `Error ${res.status}`, res.status, json?.error, retryAfter);
66
+ const headers = {};
67
+ res.headers?.forEach?.((v, k) => void (headers[k.toLowerCase()] = v));
68
+ throw new PilotHttpError(json?.message ?? json?.error ?? `Error ${res.status}`, res.status, json?.error, retryAfter, headers);
66
69
  }
67
70
  if (!res.body) return;
68
71
  const serverDate = Date.parse(res.headers.get("date") ?? "");
@@ -942,8 +942,25 @@ var __iconData37 = {
942
942
  __iconData37.node;
943
943
  var UsersRound = createLucideIcon(__iconData37);
944
944
 
945
- // ../../node_modules/lucide-react/dist/esm/icons/x.mjs
945
+ // ../../node_modules/lucide-react/dist/esm/icons/wrench.mjs
946
946
  var __iconData38 = {
947
+ name: "wrench",
948
+ size: 24,
949
+ node: [
950
+ [
951
+ "path",
952
+ {
953
+ d: "M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z",
954
+ key: "1ngwbx"
955
+ }
956
+ ]
957
+ ]
958
+ };
959
+ __iconData38.node;
960
+ var Wrench = createLucideIcon(__iconData38);
961
+
962
+ // ../../node_modules/lucide-react/dist/esm/icons/x.mjs
963
+ var __iconData39 = {
947
964
  name: "x",
948
965
  size: 24,
949
966
  node: [
@@ -951,8 +968,8 @@ var __iconData38 = {
951
968
  ["path", { d: "m6 6 12 12", key: "d8bk6v" }]
952
969
  ]
953
970
  };
954
- __iconData38.node;
955
- var X = createLucideIcon(__iconData38);
971
+ __iconData39.node;
972
+ var X = createLucideIcon(__iconData39);
956
973
 
957
974
  // src/react/Chips.tsx
958
975
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -1033,6 +1050,9 @@ function mimeOf(file) {
1033
1050
  const ext = (file.name.match(/\.([A-Za-z0-9]+)$/)?.[1] || "").toLowerCase();
1034
1051
  return BY_EXTENSION[ext] ?? "application/octet-stream";
1035
1052
  }
1053
+ function accepts(accept, name, mime) {
1054
+ return accept.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean).some((rule) => rule.startsWith(".") ? name.toLowerCase().endsWith(rule) : rule.endsWith("/*") ? mime.startsWith(rule.slice(0, -1)) : mime === rule);
1055
+ }
1036
1056
  var isText = (mime) => mime.startsWith("text/") || mime === "application/json" || mime.endsWith("+json") || mime.endsWith("+xml");
1037
1057
  function shortSize(bytes) {
1038
1058
  if (bytes < 1024) return `${bytes} B`;
@@ -1073,6 +1093,7 @@ function Composer({
1073
1093
  extras,
1074
1094
  extrasEnd,
1075
1095
  handleRef,
1096
+ accept,
1076
1097
  maxFiles = 8,
1077
1098
  maxFileMb = 25
1078
1099
  }) {
@@ -1129,6 +1150,10 @@ function Composer({
1129
1150
  rejected.push(t.tooBig(file.name, maxFileMb));
1130
1151
  continue;
1131
1152
  }
1153
+ if (accept && !accepts(accept, file.name, mime)) {
1154
+ rejected.push(t.cantUpload(file.name));
1155
+ continue;
1156
+ }
1132
1157
  if (next.length >= maxFiles) {
1133
1158
  rejected.push(t.tooMany(maxFiles));
1134
1159
  break;
@@ -1150,7 +1175,7 @@ function Composer({
1150
1175
  setNotice(rejected.length ? rejected.join(" \xB7 ") : null);
1151
1176
  ref.current?.focus({ preventScroll: true });
1152
1177
  },
1153
- [busy, canSeeImages, upload, maxFiles, maxFileMb, t]
1178
+ [busy, canSeeImages, upload, maxFiles, maxFileMb, accept, t]
1154
1179
  );
1155
1180
  useEffect(() => {
1156
1181
  if (!withFiles) return;
@@ -1434,7 +1459,7 @@ function Composer({
1434
1459
  /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between gap-2", children: [
1435
1460
  /* @__PURE__ */ jsxs2("div", { className: "@container flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden", children: [
1436
1461
  withFiles && /* @__PURE__ */ jsxs2(Fragment2, { children: [
1437
- /* @__PURE__ */ jsx2(
1462
+ (!accept || accepts(accept, "x.png", "image/png")) && /* @__PURE__ */ jsx2(
1438
1463
  "button",
1439
1464
  {
1440
1465
  type: "button",
@@ -1451,13 +1476,13 @@ function Composer({
1451
1476
  }
1452
1477
  ),
1453
1478
  extras,
1454
- /* @__PURE__ */ jsx2(
1479
+ (!accept || !/^\s*image\/\*\s*$/.test(accept)) && /* @__PURE__ */ jsx2(
1455
1480
  "button",
1456
1481
  {
1457
1482
  type: "button",
1458
1483
  onClick: () => {
1459
1484
  if (!fileRef.current) return;
1460
- fileRef.current.accept = "";
1485
+ fileRef.current.accept = accept ?? "";
1461
1486
  fileRef.current.click();
1462
1487
  },
1463
1488
  disabled: busy || files.length >= maxFiles,
@@ -1583,6 +1608,11 @@ function pluginPoints(p) {
1583
1608
  add(p.message?.assistant, "message.assistant");
1584
1609
  for (const k of Object.keys(p.message?.custom ?? {})) out.push(`message.custom[${k}]`);
1585
1610
  add(p.message?.formatUserText, "message.formatUserText");
1611
+ add(p.message?.beforeSend, "message.beforeSend");
1612
+ add(p.message?.avatar, "message.avatar");
1613
+ for (const k of Object.keys(p.markdown?.components ?? {})) out.push(`markdown.components[${k}]`);
1614
+ add(p.composer?.replace, "composer.replace");
1615
+ add(p.onHttpError, "onHttpError");
1586
1616
  add(p.composer?.actions, "composer.actions");
1587
1617
  add(p.composer?.actionsEnd, "composer.actionsEnd");
1588
1618
  add(p.composer?.above, "composer.above");
@@ -1611,6 +1641,9 @@ function resolvePlugins(list = []) {
1611
1641
  toolRenders: {},
1612
1642
  customRenders: {},
1613
1643
  blockRenders: {},
1644
+ components: {},
1645
+ replacers: [],
1646
+ httpError: [],
1614
1647
  attachments: {},
1615
1648
  header: [],
1616
1649
  sidePanel: [],
@@ -1633,6 +1666,7 @@ function resolvePlugins(list = []) {
1633
1666
  const transforms = [];
1634
1667
  const linkHandlers = [];
1635
1668
  const formatters = [];
1669
+ const beforeSends = [];
1636
1670
  const names = /* @__PURE__ */ new Set();
1637
1671
  const first = (slot, key, plugin, value, point) => {
1638
1672
  if (slot[key]) r.warnings.push(`\xAB${plugin}\xBB y \xAB${slot[key].plugin}\xBB dibujan ${point}[${key}]: gana \xAB${slot[key].plugin}\xBB (va antes).`);
@@ -1654,13 +1688,18 @@ function resolvePlugins(list = []) {
1654
1688
  for (const [k, v] of Object.entries(p.message?.custom ?? {})) first(r.customRenders, k, p.name, v, "message.custom");
1655
1689
  for (const [k, v] of Object.entries(p.markdown?.blocks ?? {})) first(r.blockRenders, k, p.name, v, "markdown.blocks");
1656
1690
  if (p.styles === "ghostykit") r.ghostykitStyled.add(p.name);
1691
+ for (const [k, v] of Object.entries(p.markdown?.components ?? {})) first(r.components, k, p.name, v, "markdown.components");
1692
+ if (p.message?.avatar) r.avatar ? r.warnings.push(`\xAB${p.name}\xBB tambi\xE9n da message.avatar: gana \xAB${r.avatar.plugin}\xBB.`) : r.avatar = own(p.message.avatar);
1693
+ if (p.message?.beforeSend) beforeSends.push(p.message.beforeSend);
1694
+ if (p.composer?.replace) r.replacers.push(own(p.composer.replace));
1695
+ if (p.onHttpError) r.httpError.push(own(p.onHttpError));
1657
1696
  if (p.composer?.actionsEnd) r.composerActionsEnd.push(own(p.composer.actionsEnd));
1658
1697
  if (p.composer?.below) r.composerBelow.push(own(p.composer.below));
1659
1698
  if (p.message?.assistantBefore) r.messageBefore.push(own(p.message.assistantBefore));
1660
1699
  if (p.wrap) r.wraps.push(own(p.wrap));
1661
1700
  if (p.onTurnStart) r.turnStart.push(own(p.onTurnStart));
1662
1701
  if (p.onTurnDone) r.turnDone.push(own(p.onTurnDone));
1663
- for (const key of ["upload", "transcribe", "voiceNoteText"]) {
1702
+ for (const key of ["upload", "transcribe", "voiceNoteText", "accept", "maxFiles", "maxBytes"]) {
1664
1703
  const v = p.composer?.attachments?.[key];
1665
1704
  if (!v) continue;
1666
1705
  if (r.attachments[key]) r.warnings.push(`\xAB${p.name}\xBB tambi\xE9n da composer.attachments.${key}: gana el primero.`);
@@ -1685,6 +1724,7 @@ function resolvePlugins(list = []) {
1685
1724
  }
1686
1725
  if (transforms.length) r.markdown.transform = (md, turn) => transforms.reduce((acc, f) => f(acc, turn), md);
1687
1726
  if (linkHandlers.length) r.markdown.onLinkClick = (href, ev) => linkHandlers.some((f) => f(href, ev));
1727
+ if (beforeSends.length) r.beforeSend = (t, ctx) => beforeSends.reduce((acc, f) => f(acc, ctx), t);
1688
1728
  if (formatters.length) r.formatUserText = (t) => formatters.reduce((acc, f) => f(acc), t);
1689
1729
  return r;
1690
1730
  }
@@ -1736,6 +1776,7 @@ export {
1736
1776
  Send,
1737
1777
  ShieldAlert,
1738
1778
  UsersRound,
1779
+ Wrench,
1739
1780
  X,
1740
1781
  STRINGS_ES,
1741
1782
  STRINGS_EN,
@@ -24,7 +24,7 @@ import {
24
24
  subagentsOf,
25
25
  takeQueued,
26
26
  toPermission
27
- } from "../chunks/chunk-FWBUIGTC.js";
27
+ } from "../chunks/chunk-7M2B7VKH.js";
28
28
  import {
29
29
  TOOL_VERBS_EN,
30
30
  TOOL_VERBS_ES,
@@ -159,6 +159,13 @@ export interface PilotState {
159
159
  /** Cuándo empezó el turno que ESTE cliente encargó (0 = ninguno). */
160
160
  turnStartedAt: number;
161
161
  runId: string | null;
162
+ /** El último rechazo HTTP del servidor (antes del stream): para avisos propios (p. ej. 403 + `x-needs-login`). Se limpia al mandar. */
163
+ httpError?: {
164
+ status: number;
165
+ code?: string;
166
+ message: string;
167
+ headers?: Record<string, string>;
168
+ } | null;
162
169
  /** El estado compartido del agente (`STATE_SNAPSHOT`, con `STATE_DELTA` aplicado encima). */
163
170
  agentState: Record<string, unknown>;
164
171
  /** El paso con nombre en curso (`STEP_STARTED`), p. ej. `waking`. */
@@ -3,7 +3,7 @@ import {
3
3
  definePlugin,
4
4
  pluginPoints,
5
5
  pluginRequires
6
- } from "../chunks/chunk-ZIUOXG7B.js";
6
+ } from "../chunks/chunk-PNU3LKWG.js";
7
7
  import "../chunks/chunk-UUH37KXA.js";
8
8
  import "../chunks/chunk-6U545WJG.js";
9
9
 
@@ -40,10 +40,14 @@ export interface AgentBubbleProps {
40
40
  renderTools?: (turn: Turn, live: boolean) => ReactNode;
41
41
  /** Antes del texto de la respuesta, bajo sus pasos. */
42
42
  renderBefore?: (turn: Turn, live: boolean) => ReactNode;
43
+ /** `false`: el texto antes de cada herramienta NO se vuelve paso con palomita (todo es respuesta). */
44
+ narration?: boolean;
45
+ /** El avatar del agente a la izquierda de la respuesta. */
46
+ avatar?: ReactNode;
43
47
  /** Bloques con nombre (```eb-file```) que salen del texto y se pintan como tarjetas bajo la respuesta. */
44
48
  blocks?: {
45
49
  langs: string[];
46
50
  render: (blocks: TextBlock[], turn: Turn, live: boolean) => ReactNode;
47
51
  };
48
52
  }
49
- export declare function AgentBubble({ turn, live, onRetry, onOption, showUsage, showCopy, showDuration, renderDeliveries, renderTools, renderBefore, blocks }: AgentBubbleProps): import("react").JSX.Element;
53
+ export declare function AgentBubble(props: AgentBubbleProps): import("react").JSX.Element;
@@ -16,6 +16,8 @@ export interface ComposerHandle {
16
16
  }
17
17
  /** El tipo del archivo, deducido del nombre cuando el navegador no lo dice (pasa más de lo que parece). */
18
18
  export declare function mimeOf(file: File): string;
19
+ /** ¿Cabe en un `accept` («image/*,.pdf,application/json»)? */
20
+ export declare function accepts(accept: string, name: string, mime: string): boolean;
19
21
  export interface ComposerProps {
20
22
  onSubmit: (text: string, attachments: Attachment[]) => void;
21
23
  onStop?: () => void;
@@ -46,10 +48,12 @@ export interface ComposerProps {
46
48
  extrasEnd?: ReactNode;
47
49
  /** Para agregar adjuntos desde afuera. */
48
50
  handleRef?: Ref<ComposerHandle>;
51
+ /** Qué se puede adjuntar (como el `accept` de un input). Default: todo. */
52
+ accept?: string;
49
53
  maxFiles?: number;
50
54
  maxFileMb?: number;
51
55
  }
52
- export declare function Composer({ onSubmit, onStop, busy, canSteer, placeholder, initialValue, autoFocus, canSeeImages, attachments: withFiles, upload, transcribe, voiceNoteText, models, currentModel, onModelChange, references, onRemoveReference, onActivateReference, extras, extrasEnd, handleRef, maxFiles, maxFileMb, }: ComposerProps): import("react").JSX.Element;
56
+ export declare function Composer({ onSubmit, onStop, busy, canSteer, placeholder, initialValue, autoFocus, canSeeImages, attachments: withFiles, upload, transcribe, voiceNoteText, models, currentModel, onModelChange, references, onRemoveReference, onActivateReference, extras, extrasEnd, handleRef, accept, maxFiles, maxFileMb, }: ComposerProps): import("react").JSX.Element;
53
57
  /**
54
58
  * El selector de modelo. PORTADO de Ghosty (`ModelPicker.tsx`): dropdown discreto hacia arriba, sin
55
59
  * Radix; cierra con clic fuera y con Escape.
@@ -19,6 +19,11 @@ export interface PilotoFeatures {
19
19
  duration?: boolean;
20
20
  /** Mandar con el run vivo (steer). Default: lo que diga el agente en `state.caps.steer`. */
21
21
  steer?: boolean;
22
+ /**
23
+ * Lo que el agente dice ANTES de cada herramienta como pasos con palomita («Reviso tus crons.»).
24
+ * Default: sí. `false`: todo el texto es respuesta (los pasos siguen en su caja).
25
+ */
26
+ narration?: boolean;
22
27
  }
23
28
  export interface PilotoProps extends UsePilotOptions {
24
29
  transport: PilotTransport | null;
@@ -11,6 +11,8 @@ export type PilotTheme = "light" | "dark" | "auto";
11
11
  export interface MarkdownOptions {
12
12
  /** Reescribe el markdown antes de pintarlo (p. ej. `#12` → `[#12](#folio-12)`). */
13
13
  transform?: (md: string, turn?: Turn) => string;
14
+ /** Cómo se pinta un elemento del markdown, por etiqueta (`a`, `code`…). */
15
+ components?: Record<string, ComponentType<any>>;
14
16
  /** Un clic en un enlace. Devuelve `true` si la app lo manejó (no se abre el aviso de salida). */
15
17
  onLinkClick?: (href: string, event: MouseEvent) => boolean;
16
18
  }
@@ -24,12 +24,12 @@ import {
24
24
  subagentsOf,
25
25
  takeQueued,
26
26
  toPermission
27
- } from "../chunks/chunk-FWBUIGTC.js";
27
+ } from "../chunks/chunk-7M2B7VKH.js";
28
28
  import {
29
29
  PilotHttpError,
30
30
  createAgUiTransport,
31
31
  readSse
32
- } from "../chunks/chunk-TMEHQZWL.js";
32
+ } from "../chunks/chunk-KG23PQI2.js";
33
33
  import {
34
34
  Activity,
35
35
  AppWindow,
@@ -70,6 +70,7 @@ import {
70
70
  Suggestions,
71
71
  TurnContext,
72
72
  UsersRound,
73
+ Wrench,
73
74
  X,
74
75
  definePlugin,
75
76
  isDev,
@@ -79,7 +80,7 @@ import {
79
80
  resolvePlugins,
80
81
  rootProps,
81
82
  usePiloto
82
- } from "../chunks/chunk-ZIUOXG7B.js";
83
+ } from "../chunks/chunk-PNU3LKWG.js";
83
84
  import {
84
85
  TOOL_VERBS_EN,
85
86
  TOOL_VERBS_ES,
@@ -441,7 +442,7 @@ function Markdown({ children, animate = false, className }) {
441
442
  {
442
443
  onClickCapture: onClick,
443
444
  className: `ghosty-md min-w-0 max-w-full break-words ${className ?? "text-text-primary"} [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto`,
444
- children: animate ? /* @__PURE__ */ jsx3(ChunksContext.Provider, { value: { registry, part: 0 }, children: /* @__PURE__ */ jsx3(Streamdown, { BlockComponent: ChunkedBlock, linkSafety, children: text }) }) : /* @__PURE__ */ jsx3(Streamdown, { linkSafety, children: text })
445
+ children: animate ? /* @__PURE__ */ jsx3(ChunksContext.Provider, { value: { registry, part: 0 }, children: /* @__PURE__ */ jsx3(Streamdown, { BlockComponent: ChunkedBlock, linkSafety, components: markdown.components, children: text }) }) : /* @__PURE__ */ jsx3(Streamdown, { linkSafety, components: markdown.components, children: text })
445
446
  }
446
447
  )
447
448
  );
@@ -544,7 +545,7 @@ var DEFAULT_TOOL_ICONS = {
544
545
  };
545
546
  function iconFor(t, icons) {
546
547
  for (const name of toolNames(t)) if (icons[name]) return icons[name];
547
- return t.kind && (icons[t.kind] ?? DEFAULT_TOOL_ICONS[t.kind]) || ClawIcon;
548
+ return t.kind && (icons[t.kind] ?? DEFAULT_TOOL_ICONS[t.kind]) || icons["*"] || Wrench;
548
549
  }
549
550
  var VISIBLE = 5;
550
551
  function AgentSteps({ tools, live }) {
@@ -777,14 +778,22 @@ function TurnFooter({ text, seconds, onRetry, showCopy = true, showDuration = tr
777
778
  ] })
778
779
  ] });
779
780
  }
780
- function AgentBubble({ turn, live, onRetry, onOption, showUsage = true, showCopy = true, showDuration = true, renderDeliveries, renderTools, renderBefore, blocks }) {
781
+ function AgentBubble(props) {
782
+ const { turn, avatar } = props;
783
+ const body = /* @__PURE__ */ jsx6(AgentBody, { ...props, showUsage: props.showUsage ?? true, showCopy: props.showCopy ?? true, showDuration: props.showDuration ?? true });
784
+ return /* @__PURE__ */ jsx6(TurnContext.Provider, { value: turn, children: avatar ? /* @__PURE__ */ jsxs4("div", { className: "flex items-start gap-3", children: [
785
+ /* @__PURE__ */ jsx6("div", { className: "mt-0.5 shrink-0", children: avatar }),
786
+ body
787
+ ] }) : body });
788
+ }
789
+ function AgentBody({ turn, live, onRetry, onOption, showUsage, showCopy, showDuration, renderDeliveries, renderTools, renderBefore, blocks, narration = true }) {
781
790
  const { strings: t } = usePiloto();
782
791
  const extracted = blocks ? extractBlocks(turn.text ?? "", blocks.langs, turn.cuts) : { text: turn.text ?? "", cuts: turn.cuts, blocks: [] };
783
792
  const closed = !live && !turn.tools?.some((x) => x.status === "pending");
784
- const narrated = splitNarration(extracted.text, extracted.cuts, closed);
793
+ const narrated = narration ? splitNarration(extracted.text, extracted.cuts, closed) : { steps: [], rest: extracted.text };
785
794
  const visible = narrated.rest;
786
795
  const options = onOption && !live && visible ? extractReplyOptions(visible) : null;
787
- return /* @__PURE__ */ jsx6(TurnContext.Provider, { value: turn, children: /* @__PURE__ */ jsxs4("div", { className: "gc-msg-agent min-w-0 max-w-[90%]", children: [
796
+ return /* @__PURE__ */ jsxs4("div", { className: "gc-msg-agent min-w-0 max-w-[90%]", children: [
788
797
  turn.thought && /* @__PURE__ */ jsxs4("details", { className: "mb-3 text-xs text-text-secondary", children: [
789
798
  /* @__PURE__ */ jsx6("summary", { className: "cursor-pointer select-none", children: t.thinking }),
790
799
  /* @__PURE__ */ jsx6("p", { className: "mt-2 whitespace-pre-wrap border-l-2 border-border-secondary pl-3", children: turn.thought })
@@ -805,7 +814,7 @@ function AgentBubble({ turn, live, onRetry, onOption, showUsage = true, showCopy
805
814
  turn.decisions?.length ? /* @__PURE__ */ jsx6(DecisionLines, { decisions: turn.decisions }) : null,
806
815
  showUsage && turn.usage && /* @__PURE__ */ jsx6(UsageStats, { ...turn.usage }),
807
816
  turn.text && !live && /* @__PURE__ */ jsx6(TurnFooter, { text: turn.text, seconds: turn.seconds, onRetry, showCopy, showDuration })
808
- ] }) });
817
+ ] });
809
818
  }
810
819
 
811
820
  // src/react/Status.tsx
@@ -948,11 +957,13 @@ function usePilot(transport, opts = {}) {
948
957
  if (!steering && (input.messages.length || input.resume?.length || input.forwardedProps?.continueRun)) liveRun.current = input.runId;
949
958
  optsRef.current.onSend?.(input);
950
959
  let ended = false;
960
+ let gotEvent = false;
951
961
  try {
952
962
  await t.run(
953
963
  input,
954
964
  (e) => {
955
965
  if (c.signal.aborted) return;
966
+ gotEvent = true;
956
967
  optsRef.current.onEvent?.(e);
957
968
  for (const fn of listeners.current) fn(e);
958
969
  if (e.type === "RUN_FINISHED" || e.type === "RUN_ERROR") {
@@ -965,6 +976,11 @@ function usePilot(transport, opts = {}) {
965
976
  );
966
977
  } catch (err) {
967
978
  if (c.signal.aborted) return;
979
+ if (gotEvent && !(err instanceof PilotHttpError)) {
980
+ if (!reopening) return runNow({ messages: [] }, true);
981
+ commit(sendFailed(ref.current, "Se cort\xF3 la conexi\xF3n con el agente."));
982
+ return;
983
+ }
968
984
  const code = err instanceof PilotHttpError ? err.code : void 0;
969
985
  if (code === "run_not_live" && steering && userMsg) {
970
986
  const { text } = fromUserMessage(userMsg.content);
@@ -1002,7 +1018,8 @@ function usePilot(transport, opts = {}) {
1002
1018
  commit({ ...ref.current, notice: err instanceof Error && err.message ? err.message : null, effects: [] });
1003
1019
  return runNow({ messages: [] }, true);
1004
1020
  }
1005
- commit(sendFailed(ref.current, err instanceof Error && err.message ? err.message : "No se pudo hablar con el agente."));
1021
+ const failed = sendFailed(ref.current, err instanceof Error && err.message ? err.message : "No se pudo hablar con el agente.");
1022
+ commit(err instanceof PilotHttpError ? { ...failed, httpError: { status: err.status, ...err.code ? { code: err.code } : {}, message: err.message, ...err.headers ? { headers: err.headers } : {} } } : failed);
1006
1023
  return;
1007
1024
  }
1008
1025
  if (c.signal.aborted) return;
@@ -1189,6 +1206,18 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1189
1206
  useEffect5(() => {
1190
1207
  if (isDev()) for (const w of plugins.warnings) warnOnce(w);
1191
1208
  }, [plugins]);
1209
+ const ctxForComponents = useRef4(null);
1210
+ const boundComponents = useMemo3(() => {
1211
+ const entries = Object.entries(plugins.components);
1212
+ if (!entries.length) return void 0;
1213
+ return Object.fromEntries(
1214
+ entries.map(([tag, { plugin, value: C }]) => [
1215
+ tag,
1216
+ // Como toda vista de plugin: marcada con su nombre y, si es de la app, aislada de las utilidades.
1217
+ (p) => /* @__PURE__ */ jsx8("span", { ...pluginAttrs(plugin, plugins.ghostykitStyled), style: { display: "contents" }, children: /* @__PURE__ */ jsx8(C, { ...p, ctx: ctxForComponents.current }) })
1218
+ ])
1219
+ );
1220
+ }, [plugins]);
1192
1221
  const strings = useMemo3(() => ({ ...locale === "en" ? STRINGS_EN : STRINGS_ES, ...plugins.strings }), [locale, plugins]);
1193
1222
  const config = useMemo3(
1194
1223
  () => ({
@@ -1198,9 +1227,9 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1198
1227
  formatUserText: plugins.formatUserText ? (x) => plugins.formatUserText(cleanUserText(x)) : cleanUserText,
1199
1228
  theme,
1200
1229
  skin: props.skin ?? "ghosty",
1201
- markdown: plugins.markdown
1230
+ markdown: { ...plugins.markdown, ...boundComponents ? { components: boundComponents } : {} }
1202
1231
  }),
1203
- [strings, locale, plugins, theme, props.skin]
1232
+ [strings, locale, plugins, theme, props.skin, boundComponents]
1204
1233
  );
1205
1234
  const t = strings;
1206
1235
  const [ownRefs, setOwnRefs] = useState8([]);
@@ -1235,12 +1264,16 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1235
1264
  const send = useCallback2(
1236
1265
  (text, attachments = []) => {
1237
1266
  const current = refsRef.current;
1238
- const full = applyReferences(text, current);
1267
+ const before = pluginsRef.current.beforeSend;
1268
+ const full = applyReferences(before && ctxRef.current ? before(text, ctxRef.current) : text, current);
1239
1269
  if (current.length) setRefs([]);
1240
1270
  void pilot.send(full, attachments, current.length ? { references: current } : {});
1241
1271
  },
1242
1272
  [pilot, setRefs]
1243
1273
  );
1274
+ const pluginsRef = useRef4(plugins);
1275
+ pluginsRef.current = plugins;
1276
+ const ctxRef = useRef4(null);
1244
1277
  const composerHandle = useRef4(null);
1245
1278
  const focus = useCallback2(() => composerBox.current?.querySelector("textarea")?.focus({ preventScroll: true }), []);
1246
1279
  const addReference = useCallback2(
@@ -1256,10 +1289,9 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1256
1289
  const actions = { pilot, send, addReference, addAttachments, setDraft, focus };
1257
1290
  useImperativeHandle(handle, () => actions, [pilot, send, addReference, addAttachments, setDraft, focus]);
1258
1291
  const ctx = { ...actions, agentState: state.agentState, strings, plugins: plugins.list };
1259
- const ctxRef = useRef4(ctx);
1292
+ ctxForComponents.current = ctx;
1293
+ const mark = (plugin) => pluginAttrs(plugin, plugins.ghostykitStyled);
1260
1294
  ctxRef.current = ctx;
1261
- const pluginsRef = useRef4(plugins);
1262
- pluginsRef.current = plugins;
1263
1295
  const unattended = useRef4(/* @__PURE__ */ new Set());
1264
1296
  useEffect5(
1265
1297
  () => pilot.subscribe?.((event) => {
@@ -1315,6 +1347,17 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1315
1347
  }),
1316
1348
  [pilot.onTurn]
1317
1349
  );
1350
+ useEffect5(() => {
1351
+ const err = state.httpError;
1352
+ if (!err) return;
1353
+ for (const h of pluginsRef.current.httpError) {
1354
+ try {
1355
+ h.value(err, ctxRef.current);
1356
+ } catch (e) {
1357
+ console.error(`[ghostykit] el plugin \xAB${h.plugin}\xBB fall\xF3 con onHttpError`, e);
1358
+ }
1359
+ }
1360
+ }, [state.httpError]);
1318
1361
  const [held, setHeld] = useState8({});
1319
1362
  const heldRef = useRef4(held);
1320
1363
  heldRef.current = held;
@@ -1428,12 +1471,20 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1428
1471
  const toolRunning = busy && last?.role === "assistant" && (last.tools ?? []).some(isRunning);
1429
1472
  const empty = turns.length === 0 && !busy;
1430
1473
  const placeholder = props.placeholder ?? (props.agentName ? t.placeholder(props.agentName) : t.placeholderDefault);
1431
- const { upload, transcribe, voiceNoteText } = plugins.attachments;
1474
+ const { upload, transcribe, voiceNoteText, accept, maxFiles, maxBytes } = plugins.attachments;
1432
1475
  const showFiles = features.attachments ?? Boolean(upload);
1433
1476
  const pointedRefs = refs.filter((r) => r.prompt);
1434
1477
  const plainRefs = refs.filter((r) => !r.prompt);
1435
1478
  const removeRef = (id) => setRefs(refsRef.current.filter((r) => r.id !== id));
1436
- const composer = /* @__PURE__ */ jsxs6("div", { ref: composerBox, children: [
1479
+ const replacer = plugins.replacers.find((r) => {
1480
+ try {
1481
+ return r.value.when(ctx);
1482
+ } catch {
1483
+ return false;
1484
+ }
1485
+ });
1486
+ const Replacement = replacer?.value.view;
1487
+ const composer = Replacement ? /* @__PURE__ */ jsx8("div", { ...mark(replacer.plugin), children: /* @__PURE__ */ jsx8(Replacement, { ctx }) }) : /* @__PURE__ */ jsxs6("div", { ref: composerBox, children: [
1437
1488
  /* @__PURE__ */ jsx8(ReferenceChips, { references: pointedRefs, onRemove: removeRef, onActivate: props.onReferenceClick }),
1438
1489
  /* @__PURE__ */ jsx8(
1439
1490
  Composer,
@@ -1453,7 +1504,10 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1453
1504
  onActivateReference: props.onReferenceClick,
1454
1505
  extras: plugins.composerActions.length ? /* @__PURE__ */ jsx8(Views, { items: plugins.composerActions, ctx, styled: plugins.ghostykitStyled, inline: true }) : void 0,
1455
1506
  extrasEnd: plugins.composerActionsEnd.length ? /* @__PURE__ */ jsx8(Views, { items: plugins.composerActionsEnd, ctx, styled: plugins.ghostykitStyled, inline: true }) : void 0,
1456
- handleRef: composerHandle
1507
+ handleRef: composerHandle,
1508
+ accept,
1509
+ ...maxFiles ? { maxFiles } : {},
1510
+ ...maxBytes ? { maxFileMb: maxBytes / (1024 * 1024) } : {}
1457
1511
  },
1458
1512
  draft.n
1459
1513
  )
@@ -1465,7 +1519,6 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1465
1519
  const aboveComposer = plugins.composerAbove.length ? /* @__PURE__ */ jsx8(Views, { items: plugins.composerAbove, ctx, styled: plugins.ghostykitStyled }) : null;
1466
1520
  const belowComposer = plugins.composerBelow.length ? /* @__PURE__ */ jsx8(Views, { items: plugins.composerBelow, ctx, styled: plugins.ghostykitStyled }) : null;
1467
1521
  const header = plugins.header.length ? /* @__PURE__ */ jsx8(Views, { items: plugins.header, ctx, styled: plugins.ghostykitStyled, inline: true }) : null;
1468
- const mark = (plugin) => pluginAttrs(plugin, plugins.ghostykitStyled);
1469
1522
  const Empty = plugins.empty?.value;
1470
1523
  const Waiting = plugins.waiting?.value;
1471
1524
  const renderDeliveries = (turn, live) => {
@@ -1585,7 +1638,9 @@ var Piloto = forwardRef(function Piloto2(props, handle) {
1585
1638
  renderDeliveries,
1586
1639
  renderTools,
1587
1640
  renderBefore,
1588
- blocks
1641
+ blocks,
1642
+ narration: features.narration !== false,
1643
+ avatar: plugins.avatar ? /* @__PURE__ */ jsx8("span", { ...mark(plugins.avatar.plugin), children: /* @__PURE__ */ jsx8(plugins.avatar.value, { ctx, turn }) }) : void 0
1589
1644
  }
1590
1645
  ),
1591
1646
  turn.role === "assistant" && /* @__PURE__ */ jsx8(TurnNotes, { turn })
@@ -2,7 +2,7 @@ import type { ComponentType, ReactNode } from "react";
2
2
  import type { BaseEvent } from "@ag-ui/core";
3
3
  import type { TextBlock } from "../core/narration";
4
4
  import type { ToolVerbs } from "../core/steps";
5
- import type { Attachment, CustomEntry, Reference, ToolEntry, Turn } from "../core/types";
5
+ import type { Attachment, CustomEntry, PilotState, Reference, ToolEntry, Turn } from "../core/types";
6
6
  import type { UploadResult } from "./Composer";
7
7
  import type { MarkdownOptions, PilotIcon } from "./context";
8
8
  import type { PilotStrings } from "./strings";
@@ -52,7 +52,17 @@ export interface PluginMarkdown {
52
52
  turn: Turn;
53
53
  live: boolean;
54
54
  }>>;
55
+ /**
56
+ * Cómo se pinta un elemento del markdown, por etiqueta (`a`, `code`, `img`…): p. ej. un enlace que
57
+ * es un CTA como botón. Recibe las props del elemento y `ctx`. Por etiqueta, gana el primer plugin.
58
+ */
59
+ components?: Record<string, ComponentType<Record<string, unknown> & {
60
+ ctx: PluginCtx;
61
+ children?: ReactNode;
62
+ }>>;
55
63
  }
64
+ /** Un rechazo HTTP del servidor antes del stream (403 con `x-needs-login`, 429…). */
65
+ export type PluginHttpError = NonNullable<PilotState["httpError"]>;
56
66
  export interface PilotoPlugin {
57
67
  /** Único: aparece en `data-plugin`, en los avisos y en `pilotDevtools()`. */
58
68
  name: string;
@@ -115,6 +125,12 @@ export interface PilotoPlugin {
115
125
  }>>;
116
126
  /** El texto del usuario como se pinta (se encadena en orden). */
117
127
  formatUserText?: (text: string) => string;
128
+ /** El texto que sale, antes de mandarlo (p. ej. anteponer lo señalado). Se encadena en orden. */
129
+ beforeSend?: (text: string, ctx: PluginCtx) => string;
130
+ /** El avatar del agente junto a cada respuesta. Gana el primer plugin. */
131
+ avatar?: PluginView<{
132
+ turn: Turn;
133
+ }>;
118
134
  };
119
135
  composer?: {
120
136
  /** Botones en la barra del composer, entre la imagen y el clip («Mis archivos», «Señalar»). */
@@ -130,6 +146,20 @@ export interface PilotoPlugin {
130
146
  upload?: (file: File) => Promise<UploadResult>;
131
147
  transcribe?: (audio: Blob) => Promise<string>;
132
148
  voiceNoteText?: (transcript: string) => string;
149
+ /** Qué se puede adjuntar (como el `accept` de un input): `"image/*"` = sólo fotos. */
150
+ accept?: string;
151
+ /** Cuántos archivos a la vez (default 8). */
152
+ maxFiles?: number;
153
+ /** Tamaño máximo por archivo, en bytes (default 25 MB). */
154
+ maxBytes?: number;
155
+ };
156
+ /**
157
+ * En lugar de la caja, cuando `when(ctx)` es verdad (p. ej. la prueba se acabó: 403 con
158
+ * `x-needs-login` → «Crea tu cuenta»). Gana el primer plugin cuyo `when` es verdad.
159
+ */
160
+ replace?: {
161
+ when: (ctx: PluginCtx) => boolean;
162
+ view: PluginView;
133
163
  };
134
164
  };
135
165
  /** Arriba a la derecha, sobre el hilo. */
@@ -150,6 +180,8 @@ export interface PilotoPlugin {
150
180
  ctx: PluginCtx;
151
181
  children: ReactNode;
152
182
  }>;
183
+ /** El servidor rechazó un mensaje antes de contestar (`ctx.pilot.state.httpError` también lo guarda). */
184
+ onHttpError?: (error: PluginHttpError, ctx: PluginCtx) => void;
153
185
  /** Este cliente mandó un turno. */
154
186
  onTurnStart?: (ctx: PluginCtx) => void;
155
187
  /** Terminó un turno de este cliente (abrir la última entrega, re-leer datos). */
@@ -174,6 +206,11 @@ export interface ResolvedPlugins {
174
206
  toolRenders: Record<string, Owned<NonNullable<NonNullable<PilotoPlugin["tools"]>["render"]>[string]>>;
175
207
  customRenders: Record<string, Owned<NonNullable<NonNullable<PilotoPlugin["message"]>["custom"]>[string]>>;
176
208
  blockRenders: Record<string, Owned<NonNullable<PluginMarkdown["blocks"]>[string]>>;
209
+ components: Record<string, Owned<NonNullable<PluginMarkdown["components"]>[string]>>;
210
+ avatar?: Owned<NonNullable<NonNullable<PilotoPlugin["message"]>["avatar"]>>;
211
+ beforeSend?: (text: string, ctx: PluginCtx) => string;
212
+ replacers: Owned<NonNullable<NonNullable<PilotoPlugin["composer"]>["replace"]>>[];
213
+ httpError: Owned<NonNullable<PilotoPlugin["onHttpError"]>>[];
177
214
  attachments: NonNullable<NonNullable<PilotoPlugin["composer"]>["attachments"]>;
178
215
  empty?: Owned<PluginView>;
179
216
  waiting?: Owned<NonNullable<PilotoPlugin["waiting"]>>;
@@ -13,7 +13,9 @@ export declare class PilotHttpError extends Error {
13
13
  code?: string;
14
14
  /** Segundos de `Retry-After` (429/503). */
15
15
  retryAfter?: number;
16
- constructor(message: string, status: number, code?: string, retryAfter?: number);
16
+ /** Las cabeceras de la respuesta (minúsculas), p. ej. `x-needs-login`. */
17
+ headers?: Record<string, string>;
18
+ constructor(message: string, status: number, code?: string, retryAfter?: number, headers?: Record<string, string>);
17
19
  }
18
20
  export interface AgUiTransportOptions {
19
21
  /** El endpoint del agente: `POST <url>`. */
@@ -2,7 +2,7 @@ import {
2
2
  PilotHttpError,
3
3
  createAgUiTransport,
4
4
  readSse
5
- } from "../chunks/chunk-TMEHQZWL.js";
5
+ } from "../chunks/chunk-KG23PQI2.js";
6
6
  export {
7
7
  PilotHttpError,
8
8
  createAgUiTransport,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ghostykit",
3
- "version": "0.7.0",
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.",
3
+ "version": "0.7.2",
4
+ "description": "Ghosty Autopilot — 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",
7
7
  "types": "./dist/core/index.d.ts",