@marimo-team/frontend 0.19.5-dev40 → 0.19.5-dev44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/assets/{JsonOutput-Cm5B4ITW.js → JsonOutput-BJKlFcc0.js} +3 -3
  2. package/dist/assets/{add-cell-with-ai-DcCKbUTJ.js → add-cell-with-ai-2okCoA2d.js} +1 -1
  3. package/dist/assets/{agent-panel-UTyb4ZWt.js → agent-panel-DS2YyDty.js} +1 -1
  4. package/dist/assets/ai-model-dropdown-C-5PlP5A.js +2 -0
  5. package/dist/assets/{app-config-button-BZRH0Vm5.js → app-config-button-CWGnhJfC.js} +1 -1
  6. package/dist/assets/{cell-editor-DvMYGVZI.js → cell-editor-1qg6DEg8.js} +1 -1
  7. package/dist/assets/{chat-panel-OQJ3Uzfy.js → chat-panel-Biq3a7V5.js} +2 -2
  8. package/dist/assets/{column-preview-D7tpBZ8D.js → column-preview-oXDBchsB.js} +1 -1
  9. package/dist/assets/{command-palette-CM7uPgIZ.js → command-palette-Dk8PKzC6.js} +1 -1
  10. package/dist/assets/{dependency-graph-panel-BnTjJemH.js → dependency-graph-panel-BkcSye1r.js} +1 -1
  11. package/dist/assets/download-C_slsU-7.js +1 -0
  12. package/dist/assets/{edit-page-BH1Zauk_.js → edit-page-qFmf6UNC.js} +3 -3
  13. package/dist/assets/{file-explorer-panel-DHdkeWlc.js → file-explorer-panel-BmTuxLH7.js} +1 -1
  14. package/dist/assets/{home-page-CEjjqCrm.js → home-page-dF6IF-l_.js} +1 -1
  15. package/dist/assets/hooks-BKBouwbG.js +1 -0
  16. package/dist/assets/{index-DlwPGlGp.js → index-iQfK7AJ4.js} +3 -3
  17. package/dist/assets/{layout-CmYdEP97.js → layout-wTVz6YE7.js} +2 -2
  18. package/dist/assets/{packages-panel-g0Q5mmiP.js → packages-panel-CFeF0dwu.js} +1 -1
  19. package/dist/assets/{panels-aGfk1Uim.js → panels-CuOtdNMV.js} +1 -1
  20. package/dist/assets/{run-page-Dl5uK1BA.js → run-page-BYrxd_KZ.js} +1 -1
  21. package/dist/assets/{scratchpad-panel-DM1IMG_b.js → scratchpad-panel-B5-yknRc.js} +1 -1
  22. package/dist/assets/{session-panel-Cb33DPYR.js → session-panel-kCLI973D.js} +1 -1
  23. package/dist/assets/state-DYlTWGKl.js +1 -0
  24. package/dist/assets/useCellActionButton-DSlxpMvt.js +1 -0
  25. package/dist/assets/{useDependencyPanelTab-CelOV6ll.js → useDependencyPanelTab-BzMsMr2G.js} +1 -1
  26. package/dist/assets/useNotebookActions-sYRj2R7H.js +1 -0
  27. package/dist/assets/{utilities.esm-h8Z3Fnc4.js → utilities.esm-Dx7lHb2R.js} +1 -1
  28. package/dist/index.html +7 -7
  29. package/package.json +1 -1
  30. package/src/components/ai/__tests__/ai-utils.test.ts +276 -0
  31. package/src/components/ai/ai-utils.ts +101 -0
  32. package/src/components/app-config/ai-config.tsx +56 -16
  33. package/src/components/app-config/user-config-form.tsx +29 -0
  34. package/src/components/chat/chat-panel.tsx +3 -3
  35. package/src/components/editor/actions/useCellActionButton.tsx +2 -2
  36. package/src/components/editor/actions/useNotebookActions.tsx +18 -10
  37. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +1 -1
  38. package/src/core/ai/model-registry.ts +21 -3
  39. package/src/core/export/hooks.ts +7 -11
  40. package/src/utils/__tests__/download.test.tsx +398 -2
  41. package/src/utils/download.ts +107 -6
  42. package/dist/assets/ai-model-dropdown-DlakzOQz.js +0 -2
  43. package/dist/assets/download-B1tR5R3Y.js +0 -1
  44. package/dist/assets/hooks-DXFCXuj4.js +0 -1
  45. package/dist/assets/state-AqERlXYZ.js +0 -1
  46. package/dist/assets/useCellActionButton-CYhHKo1M.js +0 -1
  47. package/dist/assets/useNotebookActions-73PWLiXf.js +0 -1
  48. package/src/components/export/export-output-button.tsx +0 -14
@@ -115,6 +115,7 @@ interface ApiKeyProps {
115
115
  placeholder: string;
116
116
  testId: string;
117
117
  description?: React.ReactNode;
118
+ onChange?: (value: string) => void;
118
119
  }
119
120
 
120
121
  export const ApiKey: React.FC<ApiKeyProps> = ({
@@ -124,6 +125,7 @@ export const ApiKey: React.FC<ApiKeyProps> = ({
124
125
  placeholder,
125
126
  testId,
126
127
  description,
128
+ onChange,
127
129
  }) => {
128
130
  return (
129
131
  <FormField
@@ -141,11 +143,12 @@ export const ApiKey: React.FC<ApiKeyProps> = ({
141
143
  placeholder={placeholder}
142
144
  type="password"
143
145
  {...field}
144
- value={asStringOrUndefined(field.value)}
146
+ value={asStringOrEmpty(field.value)}
145
147
  onChange={(e) => {
146
148
  const value = e.target.value;
147
149
  if (!value.includes("*")) {
148
150
  field.onChange(value);
151
+ onChange?.(value);
149
152
  }
150
153
  }}
151
154
  />
@@ -168,12 +171,12 @@ interface BaseUrlProps {
168
171
  testId: string;
169
172
  description?: React.ReactNode;
170
173
  disabled?: boolean;
171
- defaultValue?: string;
174
+ onChange?: (value: string) => void;
172
175
  }
173
176
 
174
- function asStringOrUndefined<T>(value: T): string | undefined {
177
+ function asStringOrEmpty<T>(value: T): string {
175
178
  if (value == null) {
176
- return undefined;
179
+ return "";
177
180
  }
178
181
 
179
182
  if (typeof value === "string") {
@@ -191,13 +194,12 @@ export const BaseUrl: React.FC<BaseUrlProps> = ({
191
194
  testId,
192
195
  description,
193
196
  disabled = false,
194
- defaultValue,
197
+ onChange,
195
198
  }) => {
196
199
  return (
197
200
  <FormField
198
201
  control={form.control}
199
202
  name={name}
200
- disabled={disabled}
201
203
  render={({ field }) => (
202
204
  <div className="flex flex-col space-y-1">
203
205
  <FormItem className={formItemClasses}>
@@ -208,9 +210,13 @@ export const BaseUrl: React.FC<BaseUrlProps> = ({
208
210
  rootClassName="flex-1"
209
211
  className="m-0 inline-flex h-7"
210
212
  placeholder={placeholder}
211
- defaultValue={defaultValue}
212
213
  {...field}
213
- value={asStringOrUndefined(field.value)}
214
+ value={asStringOrEmpty(field.value)}
215
+ disabled={disabled}
216
+ onChange={(e) => {
217
+ field.onChange(e.target.value);
218
+ onChange?.(e.target.value);
219
+ }}
214
220
  />
215
221
  </FormControl>
216
222
  <FormMessage />
@@ -252,9 +258,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
252
258
  <FormField
253
259
  control={form.control}
254
260
  name={name}
255
- disabled={disabled}
256
261
  render={({ field }) => {
257
- const value = asStringOrUndefined(field.value);
262
+ const value = asStringOrEmpty(field.value);
258
263
 
259
264
  const selectModel = (modelId: QualifiedModelId) => {
260
265
  field.onChange(modelId);
@@ -286,7 +291,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
286
291
  className="w-full border-border shadow-none focus-visible:shadow-xs"
287
292
  placeholder={placeholder}
288
293
  {...field}
289
- value={asStringOrUndefined(field.value)}
294
+ value={asStringOrEmpty(field.value)}
295
+ disabled={disabled}
290
296
  onKeyDown={Events.stopPropagation()}
291
297
  />
292
298
  {value && (
@@ -339,7 +345,6 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
339
345
  <FormField
340
346
  control={form.control}
341
347
  name={name}
342
- disabled={disabled}
343
348
  render={({ field }) => (
344
349
  <div className="flex flex-col space-y-1">
345
350
  <FormItem className={formItemClasses}>
@@ -354,14 +359,14 @@ export const ProviderSelect: React.FC<ProviderSelectProps> = ({
354
359
  field.onChange(e.target.value);
355
360
  }
356
361
  }}
357
- value={asStringOrUndefined(
362
+ value={asStringOrEmpty(
358
363
  field.value === true
359
364
  ? "github"
360
365
  : field.value === false
361
366
  ? "none"
362
367
  : field.value,
363
368
  )}
364
- disabled={field.disabled}
369
+ disabled={disabled}
365
370
  className="inline-flex mr-2"
366
371
  >
367
372
  {options.map((option) => (
@@ -715,6 +720,22 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
715
720
  </div>
716
721
  );
717
722
 
723
+ // Update a provider field by updating the entire custom_providers object.
724
+ // As this config will be replaced, it needs to be sent in its entirety.
725
+ const updateProviderField = (opts: {
726
+ providerName: string;
727
+ fieldName: keyof CustomProviderConfig;
728
+ value: string;
729
+ }) => {
730
+ field.onChange({
731
+ ...customProviders,
732
+ [opts.providerName]: {
733
+ ...customProviders[opts.providerName],
734
+ [opts.fieldName]: opts.value || undefined,
735
+ },
736
+ });
737
+ };
738
+
718
739
  const renderAccordionItem = ({
719
740
  providerName,
720
741
  providerConfig,
@@ -744,6 +765,13 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
744
765
  }
745
766
  placeholder="sk-..."
746
767
  testId={`custom-provider-${providerName}-api-key`}
768
+ onChange={(value) =>
769
+ updateProviderField({
770
+ providerName,
771
+ fieldName: "api_key",
772
+ value,
773
+ })
774
+ }
747
775
  />
748
776
  <BaseUrl
749
777
  form={form}
@@ -753,6 +781,13 @@ export const CustomProvidersConfig: React.FC<AiConfigProps> = ({
753
781
  }
754
782
  placeholder="https://api.example.com/v1"
755
783
  testId={`custom-provider-${providerName}-base-url`}
784
+ onChange={(value) =>
785
+ updateProviderField({
786
+ providerName,
787
+ fieldName: "base_url",
788
+ value,
789
+ })
790
+ }
756
791
  />
757
792
  <Button
758
793
  variant="destructive"
@@ -918,7 +953,6 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
918
953
  config={config}
919
954
  name="ai.ollama.base_url"
920
955
  placeholder="http://localhost:11434/v1"
921
- defaultValue="http://localhost:11434/v1"
922
956
  testId="ollama-base-url-input"
923
957
  />
924
958
  </AccordionFormItem>
@@ -1038,7 +1072,6 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
1038
1072
  config={config}
1039
1073
  name="ai.azure.base_url"
1040
1074
  placeholder="https://<your-resource-name>.openai.azure.com/openai/deployments/<deployment-name>?api-version=<api-version>"
1041
- defaultValue="https://<your-resource-name>.openai.azure.com/openai/deployments/<deployment-name>?api-version=<api-version>"
1042
1075
  testId="ai-azure-base-url-input"
1043
1076
  />
1044
1077
  </AccordionFormItem>
@@ -1427,6 +1460,13 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
1427
1460
 
1428
1461
  const deleteModel = useEvent((modelId: QualifiedModelId) => {
1429
1462
  const newModels = customModels.filter((id) => id !== modelId);
1463
+ // Remove from displayed models if it's in there
1464
+ const newDisplayedModels = currentDisplayedModels.filter(
1465
+ (id) => id !== modelId,
1466
+ );
1467
+ form.setValue("ai.models.displayed_models", newDisplayedModels, {
1468
+ shouldDirty: true,
1469
+ });
1430
1470
  form.setValue("ai.models.custom_models", newModels, {
1431
1471
  shouldDirty: true,
1432
1472
  });
@@ -50,6 +50,7 @@ import { Banner } from "@/plugins/impl/common/error-banner";
50
50
  import { THEMES } from "@/theme/useTheme";
51
51
  import { arrayToggle } from "@/utils/arrays";
52
52
  import { cn } from "@/utils/cn";
53
+ import { autoPopulateModels } from "../ai/ai-utils";
53
54
  import { keyboardShortcutsAtom } from "../editor/controls/keyboard-shortcuts";
54
55
  import { Badge } from "../ui/badge";
55
56
  import { ExternalLink } from "../ui/links";
@@ -180,6 +181,28 @@ export const UserConfigForm: React.FC = () => {
180
181
  defaultValues: config,
181
182
  });
182
183
 
184
+ const setAiModels = (values: UserConfig, dirtyAiConfig: UserConfig["ai"]) => {
185
+ const { chatModel, editModel } = autoPopulateModels(values);
186
+ if (chatModel || editModel) {
187
+ dirtyAiConfig = {
188
+ ...dirtyAiConfig,
189
+ models: {
190
+ ...dirtyAiConfig?.models,
191
+ ...(chatModel && { chat_model: chatModel }),
192
+ ...(editModel && { edit_model: editModel }),
193
+ },
194
+ } as typeof dirtyAiConfig;
195
+ if (chatModel) {
196
+ form.setValue("ai.models.chat_model", chatModel);
197
+ }
198
+ if (editModel) {
199
+ form.setValue("ai.models.edit_model", editModel);
200
+ }
201
+ }
202
+
203
+ return dirtyAiConfig;
204
+ };
205
+
183
206
  const onSubmitNotDebounced = async (values: UserConfig) => {
184
207
  // Only send values that were actually changed to avoid
185
208
  // overwriting backend values the form doesn't manage
@@ -187,6 +210,12 @@ export const UserConfigForm: React.FC = () => {
187
210
  if (Object.keys(dirtyValues).length === 0) {
188
211
  return; // Nothing changed
189
212
  }
213
+
214
+ // Auto-populate AI models when credentials are set, makes it easier to get started
215
+ if (dirtyValues.ai) {
216
+ dirtyValues.ai = setAiModels(values, dirtyValues.ai);
217
+ }
218
+
190
219
  await saveUserConfig({ config: dirtyValues }).then(() => {
191
220
  // Update local state with form values
192
221
  setConfig((prev) => ({ ...prev, ...values }));
@@ -426,14 +426,14 @@ const ChatInput: React.FC<ChatInputProps> = memo(
426
426
  ChatInput.displayName = "ChatInput";
427
427
 
428
428
  const ChatPanel = () => {
429
- const aiEnabled = useAtomValue(aiEnabledAtom);
429
+ const aiConfigured = useAtomValue(aiEnabledAtom);
430
430
  const { handleClick } = useOpenSettingsToTab();
431
431
 
432
- if (!aiEnabled) {
432
+ if (!aiConfigured) {
433
433
  return (
434
434
  <PanelEmptyState
435
435
  title="Chat with AI"
436
- description="AI is currently disabled. Add your API key to enable."
436
+ description="No AI provider configured or model selected"
437
437
  action={
438
438
  <Button variant="outline" size="sm" onClick={() => handleClick("ai")}>
439
439
  Edit AI settings
@@ -26,7 +26,6 @@ import {
26
26
  ZapIcon,
27
27
  ZapOffIcon,
28
28
  } from "lucide-react";
29
- import { downloadCellOutput } from "@/components/export/export-output-button";
30
29
  import { MultiIcon } from "@/components/icons/multi-icon";
31
30
  import { useImperativeModal } from "@/components/modal/ImperativeModal";
32
31
  import {
@@ -54,6 +53,7 @@ import { useRequestClient } from "@/core/network/requests";
54
53
  import type { CellConfig, RuntimeState } from "@/core/network/types";
55
54
  import { canLinkToCell, createCellLink } from "@/utils/cell-urls";
56
55
  import { copyToClipboard } from "@/utils/copy";
56
+ import { downloadCellOutputAsImage } from "@/utils/download";
57
57
  import { MarkdownIcon, PythonIcon } from "../cell/code/icons";
58
58
  import { useDeleteCellCallback } from "../cell/useDeleteCell";
59
59
  import { useRunCell } from "../cell/useRunCells";
@@ -341,7 +341,7 @@ export function useCellActionButtons({ cell, closePopover }: Props) {
341
341
  icon: <ImageIcon size={13} strokeWidth={1.5} />,
342
342
  label: "Export output as PNG",
343
343
  hidden: !hasOutput,
344
- handle: () => downloadCellOutput(cellId),
344
+ handle: () => downloadCellOutputAsImage(cellId, "result"),
345
345
  },
346
346
  {
347
347
  icon: <XCircleIcon size={13} strokeWidth={1.5} />,
@@ -134,6 +134,12 @@ export function useNotebookActions() {
134
134
  const sharingHtmlEnabled = resolvedConfig.sharing?.html ?? true;
135
135
  const sharingWasmEnabled = resolvedConfig.sharing?.wasm ?? true;
136
136
 
137
+ const isServerSidePdfExportEnabled = getFeatureFlag("server_side_pdf_export");
138
+ // With server side pdf export, it doesn't matter what mode we are in,
139
+ // Default export uses browser print, which is better in present mode
140
+ const pdfDownloadEnabled =
141
+ isServerSidePdfExportEnabled || viewState.mode === "present";
142
+
137
143
  const renderCheckboxElement = (checked: boolean) => (
138
144
  <div className="w-8 flex justify-end">
139
145
  {checked && <CheckIcon size={14} />}
@@ -209,22 +215,24 @@ export function useNotebookActions() {
209
215
  if (!app) {
210
216
  return;
211
217
  }
212
- await downloadHTMLAsImage(app, document.title);
218
+ await downloadHTMLAsImage({
219
+ element: app,
220
+ filename: document.title,
221
+ });
213
222
  },
214
223
  },
215
224
  {
216
225
  icon: <FileIcon size={14} strokeWidth={1.5} />,
217
226
  label: "Download as PDF",
218
- disabled: viewState.mode !== "present",
219
- tooltip:
220
- viewState.mode === "present" ? undefined : (
221
- <span>
222
- Only available in app view. <br />
223
- Toggle with: {renderShortcut("global.hideCode", false)}
224
- </span>
225
- ),
227
+ disabled: !pdfDownloadEnabled,
228
+ tooltip: pdfDownloadEnabled ? undefined : (
229
+ <span>
230
+ Only available in app view. <br />
231
+ Toggle with: {renderShortcut("global.hideCode", false)}
232
+ </span>
233
+ ),
226
234
  handle: async () => {
227
- if (getFeatureFlag("server_side_pdf_export")) {
235
+ if (isServerSidePdfExportEnabled) {
228
236
  if (!filename) {
229
237
  toastNotebookMustBeNamed();
230
238
  return;
@@ -185,7 +185,7 @@ const ActionButtons: React.FC<{
185
185
  if (!app) {
186
186
  return;
187
187
  }
188
- await downloadHTMLAsImage(app, document.title);
188
+ await downloadHTMLAsImage({ element: app, filename: document.title });
189
189
  };
190
190
 
191
191
  const handleDownloadAsHTML = async () => {
@@ -21,8 +21,17 @@ export interface AiModel extends AiModelType {
21
21
  custom: boolean;
22
22
  }
23
23
 
24
- const getKnownModelMap = once((): ReadonlyMap<QualifiedModelId, AiModel> => {
24
+ interface KnownModelMaps {
25
+ /** Map of qualified model ID to model info */
26
+ modelMap: ReadonlyMap<QualifiedModelId, AiModel>;
27
+ /** Map of provider ID to first default model (supports chat or edit) */
28
+ defaultModelByProvider: ReadonlyMap<ProviderId, QualifiedModelId>;
29
+ }
30
+
31
+ export const getKnownModelMaps = once((): KnownModelMaps => {
25
32
  const modelMap = new Map<QualifiedModelId, AiModel>();
33
+ const defaultModelByProvider = new Map<ProviderId, QualifiedModelId>();
34
+
26
35
  for (const model of models) {
27
36
  const modelId = model.model as ShortModelId;
28
37
  const modelInfo: AiModel = {
@@ -33,12 +42,21 @@ const getKnownModelMap = once((): ReadonlyMap<QualifiedModelId, AiModel> => {
33
42
  custom: false,
34
43
  };
35
44
 
45
+ const supportsChatOrEdit =
46
+ modelInfo.roles.includes("chat") || modelInfo.roles.includes("edit");
47
+
36
48
  for (const provider of modelInfo.providers) {
37
49
  const qualifiedModelId: QualifiedModelId = `${provider}/${modelId}`;
38
50
  modelMap.set(qualifiedModelId, modelInfo);
51
+
52
+ // Track first model per provider that supports chat or edit
53
+ if (supportsChatOrEdit && !defaultModelByProvider.has(provider)) {
54
+ defaultModelByProvider.set(provider, qualifiedModelId);
55
+ }
39
56
  }
40
57
  }
41
- return modelMap;
58
+
59
+ return { modelMap, defaultModelByProvider };
42
60
  });
43
61
 
44
62
  const getProviderMap = once(
@@ -125,7 +143,7 @@ export class AiModelRegistry {
125
143
  }) {
126
144
  const { displayedModels, customModels } = opts;
127
145
  const hasDisplayedModels = displayedModels.size > 0;
128
- const knownModelMap = getKnownModelMap();
146
+ const knownModelMap = getKnownModelMaps().modelMap;
129
147
  const customModelsMap = new Map<QualifiedModelId, AiModel>();
130
148
 
131
149
  let modelsMap = new Map<QualifiedModelId, AiModel>();
@@ -1,14 +1,14 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { toPng } from "html-to-image";
3
2
  import { atom, useAtom, useAtomValue } from "jotai";
4
3
  import type { MimeType } from "@/components/editor/Output";
5
4
  import { toast } from "@/components/ui/use-toast";
6
5
  import { appConfigAtom } from "@/core/config/config";
7
6
  import { useInterval } from "@/hooks/useInterval";
7
+ import { getImageDataUrlForCell } from "@/utils/download";
8
8
  import { Logger } from "@/utils/Logger";
9
9
  import { Objects } from "@/utils/objects";
10
10
  import { cellsRuntimeAtom } from "../cells/cells";
11
- import { type CellId, CellOutputId } from "../cells/ids";
11
+ import type { CellId } from "../cells/ids";
12
12
  import { connectionAtom } from "../network/connection";
13
13
  import { useRequestClient } from "../network/requests";
14
14
  import type { UpdateCellOutputsRequest } from "../network/types";
@@ -131,16 +131,12 @@ export function useEnrichCellOutputs() {
131
131
  // Capture screenshots
132
132
  const results = await Promise.all(
133
133
  cellsToCaptureScreenshot.map(async ([cellId]) => {
134
- const outputElement = document.getElementById(
135
- CellOutputId.create(cellId),
136
- );
137
- if (!outputElement) {
138
- Logger.error(`Output element not found for cell ${cellId}`);
139
- return null;
140
- }
141
-
142
134
  try {
143
- const dataUrl = await toPng(outputElement);
135
+ const dataUrl = await getImageDataUrlForCell(cellId);
136
+ if (!dataUrl) {
137
+ Logger.error(`Failed to capture screenshot for cell ${cellId}`);
138
+ return null;
139
+ }
144
140
  return [cellId, ["image/png", dataUrl]] as [
145
141
  CellId,
146
142
  ["image/png", string],