skydive-cli 0.2.0-beta.482 → 0.2.0-beta.509

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
@@ -176,6 +176,13 @@ internal API. On launch it walks you through:
176
176
  formatting, collapsed reasoning, and rich rendering of tool calls
177
177
  (`bash`, `edit`/`write` diffs, `read`, `grep`/`glob`).
178
178
 
179
+ You can change conversation without leaving chat. `/conversation` (aliases
180
+ `/conversations`, `/conv`) reopens the conversation list, and
181
+ `/conversation <id>` — an id, its `/c/<id>` link, or a pasted
182
+ `skydive chat --resume <id>` line — jumps straight into that thread, including
183
+ one belonging to a **different** agent. Either way the run you leave behind
184
+ keeps going server-side; reopening it reattaches to the stream.
185
+
179
186
  Runs under **Node** like every other command. The TUI renders through
180
187
  OpenTUI, whose native core needs the [Bun](https://bun.sh) runtime, so the
181
188
  first `chat` run downloads a private, pinned Bun and re-execs under it
package/dist/js/bin.mjs CHANGED
@@ -20,7 +20,7 @@ import semver from "semver";
20
20
 
21
21
  //#region package.json
22
22
  var name = "skydive-cli";
23
- var version = "0.2.0-beta.482";
23
+ var version = "0.2.0-beta.509";
24
24
 
25
25
  //#endregion
26
26
  //#region src/types.ts
@@ -1200,7 +1200,7 @@ const importCommand = {
1200
1200
  process.exit(1);
1201
1201
  }
1202
1202
  }
1203
- const { runChat } = await import("./boot-B8ownO3n.mjs");
1203
+ const { runChat } = await import("./boot-C-wL4WoP.mjs");
1204
1204
  await runChat({
1205
1205
  appUrl,
1206
1206
  sessionToken: session.value.sessionToken,
@@ -1511,7 +1511,7 @@ const chatCommand = {
1511
1511
  printError(`Unknown theme "${themeId}". Valid themes: ${themes.map((t) => t.id).join(", ")}`);
1512
1512
  process.exit(1);
1513
1513
  }
1514
- const { runChat } = await import("./boot-B8ownO3n.mjs");
1514
+ const { runChat } = await import("./boot-C-wL4WoP.mjs");
1515
1515
  await runChat({
1516
1516
  appUrl,
1517
1517
  sessionToken: session.value.sessionToken,
@@ -1810,7 +1810,7 @@ const switchCommand = {
1810
1810
  printError("The workspace picker needs the Bun runtime and it could not be set up automatically. Pass a workspace slug instead, or install Bun and retry.");
1811
1811
  process.exit(1);
1812
1812
  }
1813
- const { runWorkspacePicker } = await import("./boot-B8ownO3n.mjs");
1813
+ const { runWorkspacePicker } = await import("./boot-C-wL4WoP.mjs");
1814
1814
  await runWorkspacePicker(session);
1815
1815
  return;
1816
1816
  }
@@ -2003,6 +2003,33 @@ function relativeTime(iso) {
2003
2003
  return `${Math.floor(diffMs / 864e5)}d ago`;
2004
2004
  }
2005
2005
 
2006
+ //#endregion
2007
+ //#region src/chat/conversation-ref.ts
2008
+ /**
2009
+ * Parsing the conversation reference a user hands `/conversation`.
2010
+ *
2011
+ * The whole point of the command is that you paste whatever you already have
2012
+ * in front of you, so every form the platform hands out has to work: a bare
2013
+ * id, the `https://skydive.com/c/<id>` link an agent replies with, or the
2014
+ * `skydive chat --resume <id>` line the TUI prints on exit. All three carry
2015
+ * exactly one id, so we pull the first uuid out of the string rather than
2016
+ * teaching this about URL shapes and command syntax.
2017
+ *
2018
+ * Validating here (instead of letting the API answer) is what turns a typo
2019
+ * into "not a conversation id" in the composer rather than a request that
2020
+ * cannot succeed.
2021
+ */
2022
+ const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
2023
+ /**
2024
+ * The conversation id in `input`, lowercased, or null when there isn't one.
2025
+ * Trailing punctuation, query strings and surrounding prose are tolerated —
2026
+ * a pasted link or command line is the expected input, not the exception.
2027
+ */
2028
+ function parseConversationRef(input) {
2029
+ const match = uuidPattern.exec(input);
2030
+ return match ? match[0].toLowerCase() : null;
2031
+ }
2032
+
2006
2033
  //#endregion
2007
2034
  //#region src/chat/paste.ts
2008
2035
  const MAX_PATH_PASTE_CHARS = 4096;
@@ -4095,8 +4122,8 @@ function TodoCardView({ todos, isActive }) {
4095
4122
  //#endregion
4096
4123
  //#region src/chat/tui/chat/footer-hints.ts
4097
4124
  /** Build the chat footer copy independently of its colors and layout. */
4098
- function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer, hasActionableCard, nonWebChannel }) {
4099
- const connectHint = hasActionableCard ? "ctrl+r connect · " : "";
4125
+ function formatChatFooterStatus({ runKind, ctrlCArmed, isShared, hasPendingSteer, actionableCardCount, nonWebChannel }) {
4126
+ const connectHint = actionableCardCount > 1 ? `ctrl+r connect (${actionableCardCount}) · ` : actionableCardCount === 1 ? "ctrl+r connect · " : "";
4100
4127
  switch (runKind) {
4101
4128
  case "idle":
4102
4129
  if (ctrlCArmed) return "press ctrl+c again to quit";
@@ -4443,7 +4470,7 @@ const keybindGroups = [
4443
4470
  },
4444
4471
  {
4445
4472
  keys: "ctrl+r",
4446
- action: "run the newest connect card (auth cards)"
4473
+ action: "act on a waiting connect card (auth cards)"
4447
4474
  },
4448
4475
  {
4449
4476
  keys: "pgup / pgdn",
@@ -4887,6 +4914,13 @@ const slashCommands = [
4887
4914
  description: "start a new conversation with this agent",
4888
4915
  action: { kind: "new-conversation" }
4889
4916
  },
4917
+ {
4918
+ name: "conversation",
4919
+ aliases: ["conversations", "conv"],
4920
+ argsHint: "[id or link]",
4921
+ description: "switch conversation (no id: pick from the list)",
4922
+ action: { kind: "conversation" }
4923
+ },
4890
4924
  {
4891
4925
  name: "model",
4892
4926
  aliases: [],
@@ -4926,7 +4960,7 @@ const slashCommands = [
4926
4960
  name: "connect",
4927
4961
  aliases: [],
4928
4962
  argsHint: null,
4929
- description: "run the newest connect card (auth cards)",
4963
+ description: "act on a waiting connect card (auth cards)",
4930
4964
  action: { kind: "connect" }
4931
4965
  },
4932
4966
  {
@@ -4951,7 +4985,7 @@ const slashCommands = [
4951
4985
  *
4952
4986
  * An argument-less command only matches as the exact line — `/help me write
4953
4987
  * a plan` stays an ordinary message rather than swallowing the user's text
4954
- * into the help modal. Only `sandbox` accepts a rest.
4988
+ * into the help modal. Only `sandbox` and `conversation` accept a rest.
4955
4989
  */
4956
4990
  function matchSlashCommand(line) {
4957
4991
  if (!line.startsWith("/")) return null;
@@ -5024,6 +5058,93 @@ function printResumeHint(write = (text) => process.stdout.write(text)) {
5024
5058
  lastConversationId = null;
5025
5059
  }
5026
5060
 
5061
+ //#endregion
5062
+ //#region src/chat/tui/screens/card-picker.tsx
5063
+ /**
5064
+ * Asks which connect card ctrl+r should act on. A conversation can hold
5065
+ * several cards waiting at once (a batch of `platform auth configure` calls),
5066
+ * and the chord can't guess which one the user means — picking the newest
5067
+ * silently strands the rest, since a card left in `launched` or `error` stays
5068
+ * actionable forever. Rendered as an overlay in place of the transcript, like
5069
+ * the model and theme pickers, so the conversation and any live run survive.
5070
+ */
5071
+ function CardPicker({ cards, onSelect, onCancel }) {
5072
+ const { height } = useTerminalDimensions();
5073
+ const [highlight, setHighlight] = useState(0);
5074
+ const clamped = Math.min(highlight, Math.max(0, cards.length - 1));
5075
+ const visibleRows = Math.max(3, height - 6);
5076
+ const start = windowStart(clamped, cards.length, visibleRows);
5077
+ const windowed = cards.slice(start, start + visibleRows);
5078
+ useKeyboard((key) => {
5079
+ if (key.name === "escape") {
5080
+ onCancel();
5081
+ return;
5082
+ }
5083
+ if (key.name === "up") setHighlight(Math.max(0, clamped - 1));
5084
+ else if (key.name === "down") setHighlight(Math.min(Math.max(0, cards.length - 1), clamped + 1));
5085
+ else if (key.name === "return") {
5086
+ const card = cards[clamped];
5087
+ if (card) onSelect(card);
5088
+ }
5089
+ });
5090
+ useEffect(() => {
5091
+ if (cards.length === 0) onCancel();
5092
+ }, [cards.length, onCancel]);
5093
+ if (cards.length === 0) return null;
5094
+ return /* @__PURE__ */ jsxs("box", {
5095
+ style: {
5096
+ flexDirection: "column",
5097
+ flexGrow: 1
5098
+ },
5099
+ children: [
5100
+ /* @__PURE__ */ jsx("text", {
5101
+ fg: theme.accent,
5102
+ children: "connect"
5103
+ }),
5104
+ /* @__PURE__ */ jsx("box", {
5105
+ style: {
5106
+ flexDirection: "column",
5107
+ flexGrow: 1,
5108
+ marginTop: 1
5109
+ },
5110
+ children: windowed.map((card, i) => {
5111
+ const selected = start + i === clamped;
5112
+ const waiting = waitingOn(card);
5113
+ const label = card.card.button?.label ?? "";
5114
+ return /* @__PURE__ */ jsxs("text", {
5115
+ fg: selected ? theme.accent : theme.fg,
5116
+ children: [
5117
+ selected ? "› " : " ",
5118
+ "⚿ ",
5119
+ card.card.title,
5120
+ label ? /* @__PURE__ */ jsxs("span", {
5121
+ fg: theme.muted,
5122
+ children: [" · ", label]
5123
+ }) : null,
5124
+ waiting ? /* @__PURE__ */ jsxs("span", {
5125
+ fg: theme.dim,
5126
+ children: [" · ", waiting]
5127
+ }) : null
5128
+ ]
5129
+ }, card.id);
5130
+ })
5131
+ }),
5132
+ /* @__PURE__ */ jsxs("text", {
5133
+ fg: theme.dim,
5134
+ children: [cards.length, " waiting · ↑/↓ move · ↵ select · esc cancel"]
5135
+ })
5136
+ ]
5137
+ });
5138
+ }
5139
+ /** Why a card is still on the list, for the rows that aren't simply untouched. */
5140
+ function waitingOn(card) {
5141
+ switch (card.status) {
5142
+ case "launched": return "finish in your browser";
5143
+ case "error": return card.detail ?? "failed";
5144
+ default: return null;
5145
+ }
5146
+ }
5147
+
5027
5148
  //#endregion
5028
5149
  //#region src/chat/tui/screens/model-picker.tsx
5029
5150
  /**
@@ -7882,6 +8003,10 @@ function routeInput(raw) {
7882
8003
  kind: "sandbox-exec",
7883
8004
  command: match.rest
7884
8005
  } : { kind: "sandbox-pty" };
8006
+ case "conversation": return match.rest ? {
8007
+ kind: "open-conversation",
8008
+ ref: match.rest
8009
+ } : { kind: "conversation-picker" };
7885
8010
  case "new-conversation":
7886
8011
  case "model-picker":
7887
8012
  case "theme-picker":
@@ -8087,6 +8212,7 @@ function ChatScreen({ agent, conversation }) {
8087
8212
  const [input, setInput] = useState("");
8088
8213
  const [run, setRun] = useState({ kind: "idle" });
8089
8214
  const [model, setModel] = useState(agent.model ?? null);
8215
+ const [cardPickerOpen, setCardPickerOpen] = useState(false);
8090
8216
  const [modelPickerOpen, setModelPickerOpen] = useState(false);
8091
8217
  const [themePickerOpen, setThemePickerOpen] = useState(false);
8092
8218
  const [helpOpen, setHelpOpen] = useState(false);
@@ -8200,6 +8326,7 @@ function ChatScreen({ agent, conversation }) {
8200
8326
  };
8201
8327
  }, [
8202
8328
  grantPrompt,
8329
+ cardPickerOpen,
8203
8330
  modelPickerOpen,
8204
8331
  themePickerOpen,
8205
8332
  helpOpen,
@@ -8555,10 +8682,15 @@ function ChatScreen({ agent, conversation }) {
8555
8682
  portalClient,
8556
8683
  portal.status
8557
8684
  ]);
8558
- const runNewestCard = useCallback(() => {
8559
- const target = [...itemsRef.current].reverse().find(isActionableCard);
8560
- if (!target) return false;
8561
- runCardAction(target);
8685
+ const promptForCards = useCallback(() => {
8686
+ const targets = itemsRef.current.filter(isActionableCard);
8687
+ const [only] = targets;
8688
+ if (!only) return false;
8689
+ if (targets.length === 1) {
8690
+ runCardAction(only);
8691
+ return true;
8692
+ }
8693
+ setCardPickerOpen(true);
8562
8694
  return true;
8563
8695
  }, [runCardAction]);
8564
8696
  const handleCredInput = useCallback((displayed) => {
@@ -8803,7 +8935,7 @@ function ChatScreen({ agent, conversation }) {
8803
8935
  stageAttachment(image);
8804
8936
  }, [stageAttachment]);
8805
8937
  usePaste((event) => {
8806
- if (modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
8938
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen || credPromptOpen || grantPrompt || nonWebChannel) return;
8807
8939
  const text = event.metadata?.kind === "binary" ? "" : decodePasteBytes(event.bytes);
8808
8940
  const route = routePaste({
8809
8941
  kind: event.metadata?.kind,
@@ -8852,6 +8984,7 @@ function ChatScreen({ agent, conversation }) {
8852
8984
  if (composer.plainText === "" && inputRef.current !== "") applyComposerText(inputRef.current, "end");
8853
8985
  }, [
8854
8986
  grantPrompt,
8987
+ cardPickerOpen,
8855
8988
  modelPickerOpen,
8856
8989
  themePickerOpen,
8857
8990
  helpOpen,
@@ -8922,6 +9055,64 @@ function ChatScreen({ agent, conversation }) {
8922
9055
  agent,
8923
9056
  goTo
8924
9057
  ]);
9058
+ /**
9059
+ * Leave this conversation for another screen without ending its run: drop
9060
+ * our own SSE subscription and nothing else — never `rest.cancelRun` — so
9061
+ * the agent keeps working server-side and reopening the thread reattaches.
9062
+ * Same contract as `esc`, which is why `/conversation` is allowed mid-run
9063
+ * where `/new` is not: what you land on still lists the thread you left,
9064
+ * so the run is never stranded out of reach.
9065
+ */
9066
+ const leaveForScreen = useCallback((next) => {
9067
+ const current = runRef.current;
9068
+ if (current.kind === "streaming") current.abort.abort();
9069
+ if (current.kind !== "idle") useStore.getState().showToast({ message: `${agent.name} keeps working — reopen to reattach` });
9070
+ goTo(next);
9071
+ }, [agent.name, goTo]);
9072
+ /**
9073
+ * `/conversation <id or link>`: open that conversation without leaving the
9074
+ * TUI — the point being that the id usually arrives *from* an agent, in the
9075
+ * session you started to go find it.
9076
+ *
9077
+ * Resolution goes through `resolveInitialScreen`, the same path
9078
+ * `--resume <id>` takes, so an id belonging to a different agent opens
9079
+ * under that agent and the not-found / no-access wording lives in one
9080
+ * place.
9081
+ */
9082
+ const openConversationRef = useCallback(async (ref) => {
9083
+ const target = parseConversationRef(ref);
9084
+ if (!target) {
9085
+ setItems((prev) => [...prev, {
9086
+ kind: "error",
9087
+ id: crypto.randomUUID(),
9088
+ text: `not a conversation id: ${ref} — pass an id, its /c/<id> link, or run /conversation on its own to pick from the list`
9089
+ }]);
9090
+ return;
9091
+ }
9092
+ if (!rest) return;
9093
+ if (target === conversationId) {
9094
+ useStore.getState().showToast({ message: "already in this conversation" });
9095
+ return;
9096
+ }
9097
+ try {
9098
+ leaveForScreen(await resolveInitialScreen({
9099
+ rest,
9100
+ agentSelector: null,
9101
+ conversationId: target,
9102
+ newConversation: false
9103
+ }));
9104
+ } catch (err) {
9105
+ setItems((prev) => [...prev, {
9106
+ kind: "error",
9107
+ id: crypto.randomUUID(),
9108
+ text: `couldn't open ${target}: ${errorMessage(err)}`
9109
+ }]);
9110
+ }
9111
+ }, [
9112
+ rest,
9113
+ conversationId,
9114
+ leaveForScreen
9115
+ ]);
8925
9116
  const toggleShare = useCallback(() => {
8926
9117
  if (!portalClient) return;
8927
9118
  if (portal.status === "off") {
@@ -8993,6 +9184,15 @@ function ChatScreen({ agent, conversation }) {
8993
9184
  }
8994
9185
  });
8995
9186
  break;
9187
+ case "conversation-picker":
9188
+ leaveForScreen({
9189
+ kind: "conversation-picker",
9190
+ agent
9191
+ });
9192
+ break;
9193
+ case "open-conversation":
9194
+ openConversationRef(routed.ref);
9195
+ break;
8996
9196
  case "model-picker":
8997
9197
  setModelPickerOpen(true);
8998
9198
  break;
@@ -9024,7 +9224,7 @@ function ChatScreen({ agent, conversation }) {
9024
9224
  openInBrowser();
9025
9225
  break;
9026
9226
  case "connect":
9027
- if (!runNewestCard()) useStore.getState().showToast({ message: "no connect card waiting for action" });
9227
+ if (!promptForCards()) useStore.getState().showToast({ message: "no connect card waiting for action" });
9028
9228
  break;
9029
9229
  case "help":
9030
9230
  setHelpOpen(true);
@@ -9051,6 +9251,8 @@ function ChatScreen({ agent, conversation }) {
9051
9251
  runLocalShell,
9052
9252
  runSandboxExec,
9053
9253
  runSandboxPty,
9254
+ leaveForScreen,
9255
+ openConversationRef,
9054
9256
  toggleShare,
9055
9257
  openInBrowser,
9056
9258
  quit,
@@ -9063,7 +9265,7 @@ function ChatScreen({ agent, conversation }) {
9063
9265
  model,
9064
9266
  portal.status,
9065
9267
  portal.grantedAgentIds,
9066
- runNewestCard
9268
+ promptForCards
9067
9269
  ]);
9068
9270
  const submit = useCallback(() => {
9069
9271
  const staged = attachments.filter((a) => a.status !== "error");
@@ -9118,7 +9320,7 @@ function ChatScreen({ agent, conversation }) {
9118
9320
  }
9119
9321
  if (reviewFocused) return;
9120
9322
  }
9121
- if (modelPickerOpen || themePickerOpen || helpOpen) return;
9323
+ if (cardPickerOpen || modelPickerOpen || themePickerOpen || helpOpen) return;
9122
9324
  if (grantPrompt) {
9123
9325
  if (key.name === "y") {
9124
9326
  confirmGrant();
@@ -9210,7 +9412,7 @@ function ChatScreen({ agent, conversation }) {
9210
9412
  return;
9211
9413
  }
9212
9414
  if (key.name === "r" && key.ctrl) {
9213
- runNewestCard();
9415
+ promptForCards();
9214
9416
  return;
9215
9417
  }
9216
9418
  if (key.name === "t" && key.ctrl) {
@@ -9286,23 +9488,31 @@ function ChatScreen({ agent, conversation }) {
9286
9488
  }
9287
9489
  });
9288
9490
  const hasPendingSteer = items.some((m) => m.kind === "pending-steer");
9289
- const hasActionableCard = items.some(isActionableCard);
9491
+ const actionableCards = useMemo(() => items.filter(isActionableCard), [items]);
9290
9492
  const statusText = useMemo(() => formatChatFooterStatus({
9291
9493
  runKind: run.kind,
9292
9494
  ctrlCArmed,
9293
9495
  isShared,
9294
9496
  hasPendingSteer,
9295
- hasActionableCard,
9497
+ actionableCardCount: actionableCards.length,
9296
9498
  nonWebChannel: nonWebChannel !== null
9297
9499
  }), [
9298
9500
  run.kind,
9299
9501
  ctrlCArmed,
9300
9502
  isShared,
9301
9503
  hasPendingSteer,
9302
- hasActionableCard,
9504
+ actionableCards.length,
9303
9505
  nonWebChannel
9304
9506
  ]);
9305
9507
  const modelHint = formatModelHint(model);
9508
+ if (cardPickerOpen) return /* @__PURE__ */ jsx(CardPicker, {
9509
+ cards: actionableCards,
9510
+ onSelect: (card) => {
9511
+ setCardPickerOpen(false);
9512
+ runCardAction(card);
9513
+ },
9514
+ onCancel: () => setCardPickerOpen(false)
9515
+ });
9306
9516
  if (modelPickerOpen) return /* @__PURE__ */ jsx(ModelPicker, {
9307
9517
  agentId: agent.id,
9308
9518
  currentModel: model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skydive-cli",
3
- "version": "0.2.0-beta.482",
3
+ "version": "0.2.0-beta.509",
4
4
  "description": "Skydive CLI — cloud agents from the command line",
5
5
  "homepage": "https://skydive.com",
6
6
  "license": "MIT",