@marimo-team/frontend 0.23.10 → 0.23.11-dev10
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/dist/assets/{CellStatus-e0ex7Iei.js → CellStatus-CLGvVxcw.js} +1 -1
- package/dist/assets/{JsonOutput-DRNPZOvX.js → JsonOutput-uEJijGXp.js} +5 -5
- package/dist/assets/{MarimoErrorOutput-BH6hs0Ir.js → MarimoErrorOutput-DzoKyXWR.js} +2 -2
- package/dist/assets/{RenderHTML-BQ1PO4Wd.js → RenderHTML-DAt48X-F.js} +1 -1
- package/dist/assets/{RunButton-F8pLIvFp.js → RunButton-B7msyyYi.js} +1 -1
- package/dist/assets/{add-cell-with-ai-Bd_3tPEt.js → add-cell-with-ai-CAkcousR.js} +20 -20
- package/dist/assets/{add-connection-dialog-AhwxOztN.js → add-connection-dialog-ZyXXfAVG.js} +32 -32
- package/dist/assets/{agent-panel-Dm0KI1sF.js → agent-panel-BfIguQXh.js} +6 -6
- package/dist/assets/{ai-model-dropdown-CG4B4rqH.js → ai-model-dropdown-rE64ytSX.js} +3 -3
- package/dist/assets/{app-config-button-D_sFrSql.js → app-config-button-DLRZ5d9c.js} +1 -1
- package/dist/assets/{cell-editor-B3aYYyAI.js → cell-editor-2JIpvFn4.js} +15 -12
- package/dist/assets/{cell-link-Bj4-yOIx.js → cell-link-DY95GSb7.js} +1 -1
- package/dist/assets/{cells-DOA0Gew8.js → cells-D-v2lBet.js} +67 -67
- package/dist/assets/{chat-display-DI0jRLIv.js → chat-display-DO-tqsbY.js} +1 -1
- package/dist/assets/{chat-panel-BU4HHdf5.js → chat-panel-BT5gLfs6.js} +2 -2
- package/dist/assets/{chat-ui-C7igY2w5.js → chat-ui-Be4yDBZS.js} +4 -4
- package/dist/assets/column-preview-BzvFdLI5.js +1 -0
- package/dist/assets/{command-palette-Bjv1Z7v8.js → command-palette-CKKzfpGt.js} +1 -1
- package/dist/assets/{common-fDFYY_sv.js → common-Do4N1uVK.js} +1 -1
- package/dist/assets/{components--C6N-DXq.js → components-DUC0f1XD.js} +1 -1
- package/dist/assets/{datasource-CR6RRpTi.js → datasource-BQGI7c_u.js} +2 -2
- package/dist/assets/{dependency-graph-panel-BEgkvdX0.js → dependency-graph-panel-Czoya7c2.js} +1 -1
- package/dist/assets/{documentation-panel-HvbKykbI.js → documentation-panel-LokVYDIZ.js} +1 -1
- package/dist/assets/{download-DhxnAw14.js → download-WGU5w_3m.js} +3 -3
- package/dist/assets/{edit-page-BeWwLeT8.js → edit-page-BPQO1Uuz.js} +6 -6
- package/dist/assets/{error-panel-WLBQ3q9p.js → error-panel-BCm_e4eM.js} +1 -1
- package/dist/assets/{file-explorer-panel-DtzGlnzT.js → file-explorer-panel-D1NN-z8x.js} +3 -3
- package/dist/assets/{file-icons-D2f3nfbq.js → file-icons-CF-YT4hq.js} +1 -1
- package/dist/assets/{file-name-input-BNYf9WWM.js → file-name-input-BRVXQ3OU.js} +1 -1
- package/dist/assets/{floating-outline-BvHFWRoz.js → floating-outline-D33Zu-Ad.js} +1 -1
- package/dist/assets/{focus-Ldqh99xE.js → focus-BfFAxl9X.js} +1 -1
- package/dist/assets/{form-CxBAInfg.js → form-CVBFx2z3.js} +1 -1
- package/dist/assets/{home-page-DwFpLpXK.js → home-page-CL9Hv1Hg.js} +2 -2
- package/dist/assets/{hooks-DyMacA-R.js → hooks-83y0XCiC.js} +1 -1
- package/dist/assets/{html-to-image-CyAtzePO.js → html-to-image-IPNqWX7V.js} +2 -2
- package/dist/assets/index-BNWrEIlp.css +2 -0
- package/dist/assets/{index-fNBoXCyz.js → index-CuhY66ZR.js} +14 -14
- package/dist/assets/{kiosk-mode-CTWHjzXs.js → kiosk-mode-BSufcIZH.js} +1 -1
- package/dist/assets/{layout-dRNPwA-7.js → layout-ks0WJ2vR.js} +5 -5
- package/dist/assets/{logs-panel-BVLYycQb.js → logs-panel-DXN6sUaA.js} +1 -1
- package/dist/assets/{markdown-renderer-C9Ujvj0b.js → markdown-renderer-DWRPo52L.js} +1 -1
- package/dist/assets/{name-cell-input-DabvuruX.js → name-cell-input-BiDIx6YQ.js} +1 -1
- package/dist/assets/{outline-panel-B-ncbNWI.js → outline-panel-DVSGNRgP.js} +1 -1
- package/dist/assets/{packages-panel-CESeW_3T.js → packages-panel-DTX6CBnm.js} +1 -1
- package/dist/assets/{panels-BTEEcvYR.js → panels-NhGV0SBq.js} +1 -1
- package/dist/assets/{process-output-pPgH0ANl.js → process-output-BiWV34b7.js} +1 -1
- package/dist/assets/{radio-group-D7rh6Zek.js → radio-group-DV-lQvil.js} +1 -1
- package/dist/assets/{readonly-python-code-CvPx4CU_.js → readonly-python-code-B0glFTKW.js} +1 -1
- package/dist/assets/{reveal-component-DGQQBmed.js → reveal-component-C2M2FixA.js} +12 -12
- package/dist/assets/{run-page-yFxABzXq.js → run-page-B6I-Lshx.js} +1 -1
- package/dist/assets/{scratchpad-panel-B_nxW99u.js → scratchpad-panel-DU7perVk.js} +1 -1
- package/dist/assets/session-panel-B4UTmf-j.js +1 -0
- package/dist/assets/{snippets-panel-BSgcoo-M.js → snippets-panel-KRdl4bVk.js} +1 -1
- package/dist/assets/{state-BWM3qRkR.js → state-BSm0OM93.js} +1 -1
- package/dist/assets/{state-C8yHPSLN.js → state-CbkvENzk.js} +2 -2
- package/dist/assets/{textarea-bBuxbFm7.js → textarea-CBbixTxi.js} +1 -1
- package/dist/assets/{tracing-pt7eDHWc.js → tracing-D8LGpuXa.js} +1 -1
- package/dist/assets/{tracing-panel-BveZ5DZw.js → tracing-panel-TUP6kAd5.js} +2 -2
- package/dist/assets/{tree-actions-1KQDSu1H.js → tree-actions-BvrKFU0g.js} +1 -1
- package/dist/assets/{useCellActionButton-C4V7J6PS.js → useCellActionButton-CtOZIXD0.js} +1 -1
- package/dist/assets/{useDeleteCell-BvnurAZ9.js → useDeleteCell-BLvzLWAT.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-DNlXaxbt.js → useDependencyPanelTab-BP0sNfUz.js} +1 -1
- package/dist/assets/{useNotebookActions-Dw6tY2qa.js → useNotebookActions-BI5dWutQ.js} +1 -1
- package/dist/assets/{useRunCells-cSZpNqnR.js → useRunCells-BClkx9Pq.js} +1 -1
- package/dist/assets/{useSplitCell-s7dSiBUa.js → useSplitCell-CpddCX_u.js} +1 -1
- package/dist/index.html +23 -23
- package/package.json +1 -1
- package/src/components/datasources/__tests__/column-preview.test.tsx +97 -0
- package/src/components/datasources/__tests__/filter-empty.test.ts +81 -0
- package/src/components/datasources/__tests__/utils.test.ts +62 -1
- package/src/components/datasources/column-preview.tsx +2 -4
- package/src/components/datasources/components.tsx +15 -7
- package/src/components/datasources/datasources.tsx +311 -178
- package/src/components/datasources/utils.ts +40 -1
- package/src/components/editor/ai/ai-completion-editor.tsx +6 -5
- package/src/components/editor/connections/components.tsx +13 -0
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +4 -4
- package/src/components/editor/connections/storage/as-code.ts +11 -4
- package/src/core/ai/__tests__/strip-wrapping-backticks.test.ts +133 -0
- package/src/core/ai/stream-completion-text.ts +48 -0
- package/src/core/ai/strip-wrapping-backticks.ts +88 -0
- package/src/core/cells/__tests__/cells.test.ts +33 -0
- package/src/core/cells/cells.ts +1 -1
- package/src/core/codemirror/ai/request.ts +2 -14
- package/src/core/datasets/__tests__/data-source.test.ts +226 -0
- package/src/core/datasets/data-source-connections.ts +88 -24
- package/dist/assets/column-preview-nu3Qo2OW.js +0 -1
- package/dist/assets/index-BAYF7dcV.css +0 -2
- package/dist/assets/session-panel-CTJPYbAa.js +0 -1
|
@@ -4,15 +4,54 @@ import { BigQueryDialect } from "@marimo-team/codemirror-sql/dialects";
|
|
|
4
4
|
import { isKnownDialect } from "@/core/codemirror/language/languages/sql/utils";
|
|
5
5
|
import type { SQLTableContext } from "@/core/datasets/data-source-connections";
|
|
6
6
|
import { DUCKDB_ENGINE } from "@/core/datasets/engines";
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
Database,
|
|
9
|
+
DatabaseSchema,
|
|
10
|
+
DataTable,
|
|
11
|
+
DataType,
|
|
12
|
+
} from "@/core/kernel/messages";
|
|
8
13
|
import { logNever } from "@/utils/assertNever";
|
|
9
14
|
import type { ColumnHeaderStatsKey } from "../data-table/types";
|
|
10
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Stable id for a table node in the datasources tree.
|
|
18
|
+
*
|
|
19
|
+
* schemaPath already includes the leaf schema for nested namespaces, so use it
|
|
20
|
+
* when present and fall back to the flat schema name otherwise (avoids
|
|
21
|
+
* duplicating the leaf, e.g. `top.nested.nested.table`).
|
|
22
|
+
*/
|
|
23
|
+
export function tableUniqueId(
|
|
24
|
+
sqlTableContext: SQLTableContext | undefined,
|
|
25
|
+
tableName: string,
|
|
26
|
+
): string {
|
|
27
|
+
if (!sqlTableContext) {
|
|
28
|
+
return tableName;
|
|
29
|
+
}
|
|
30
|
+
const segments = (
|
|
31
|
+
sqlTableContext.schemaPath?.length
|
|
32
|
+
? sqlTableContext.schemaPath
|
|
33
|
+
: [sqlTableContext.schema]
|
|
34
|
+
).filter(Boolean);
|
|
35
|
+
return [sqlTableContext.database, ...segments, tableName].join(".");
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
// Some databases have no schemas, so we don't show it (eg. Clickhouse)
|
|
12
39
|
export function isSchemaless(schemaName: string) {
|
|
13
40
|
return schemaName === "";
|
|
14
41
|
}
|
|
15
42
|
|
|
43
|
+
// Lazy discovery: the `*_resolved` flags default to `true` and are only `false`
|
|
44
|
+
// when enumeration was deferred. Helper functions to centralize logic
|
|
45
|
+
export function areSchemasResolved(database: Database): boolean {
|
|
46
|
+
return database.schemas_resolved !== false;
|
|
47
|
+
}
|
|
48
|
+
export function areTablesResolved(schema: DatabaseSchema): boolean {
|
|
49
|
+
return schema.tables_resolved !== false;
|
|
50
|
+
}
|
|
51
|
+
export function areChildSchemasResolved(schema: DatabaseSchema): boolean {
|
|
52
|
+
return schema.child_schemas_resolved !== false;
|
|
53
|
+
}
|
|
54
|
+
|
|
16
55
|
interface SqlCodeFormatter {
|
|
17
56
|
/**
|
|
18
57
|
* Format the table path based on dialect-specific rules
|
|
@@ -27,7 +27,7 @@ import { Label } from "@/components/ui/label";
|
|
|
27
27
|
import { Switch } from "@/components/ui/switch";
|
|
28
28
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
29
29
|
import { toast } from "@/components/ui/use-toast";
|
|
30
|
-
import {
|
|
30
|
+
import { stripWrappingBackticks } from "@/core/ai/strip-wrapping-backticks";
|
|
31
31
|
import { type AiCompletionCell, includeOtherCellsAtom } from "@/core/ai/state";
|
|
32
32
|
import type { CellId } from "@/core/cells/ids";
|
|
33
33
|
import { getCodes } from "@/core/codemirror/copilot/getCodes";
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
RejectCompletionButton,
|
|
46
46
|
} from "./completion-handlers";
|
|
47
47
|
import { addContextCompletion, getAICompletionBody } from "./completion-utils";
|
|
48
|
+
import { stagedAICellsAtom } from "@/core/ai/staged-cells";
|
|
48
49
|
|
|
49
50
|
const Original = CodeMirrorMerge.Original;
|
|
50
51
|
const Modified = CodeMirrorMerge.Modified;
|
|
@@ -123,7 +124,6 @@ export const AiCompletionEditor: React.FC<Props> = ({
|
|
|
123
124
|
api: runtimeManager.getAiURL("completion").toString(),
|
|
124
125
|
headers: runtimeManager.headers(),
|
|
125
126
|
initialInput: initialPrompt,
|
|
126
|
-
streamProtocol: "text",
|
|
127
127
|
// Throttle the messages and data updates to 100ms
|
|
128
128
|
experimental_throttle: 100,
|
|
129
129
|
body: {
|
|
@@ -143,13 +143,14 @@ export const AiCompletionEditor: React.FC<Props> = ({
|
|
|
143
143
|
});
|
|
144
144
|
},
|
|
145
145
|
onFinish: (_prompt, completion) => {
|
|
146
|
-
|
|
147
|
-
setCompletion(completion.trimEnd());
|
|
146
|
+
setCompletion(stripWrappingBackticks(completion).trimEnd());
|
|
148
147
|
},
|
|
149
148
|
});
|
|
150
149
|
|
|
151
150
|
const inputRef = React.useRef<ReactCodeMirrorRef>(null);
|
|
152
|
-
const completion = untrimmedCompletion
|
|
151
|
+
const completion = stripWrappingBackticks(untrimmedCompletion, {
|
|
152
|
+
streaming: isLoading,
|
|
153
|
+
}).trimEnd();
|
|
153
154
|
|
|
154
155
|
const initialSubmit = useCallback(() => {
|
|
155
156
|
if (triggerImmediately && !isLoading && initialPrompt) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useAtomValue } from "jotai";
|
|
4
5
|
import React from "react";
|
|
5
6
|
import { type DefaultValues, type FieldValues, useForm } from "react-hook-form";
|
|
6
7
|
import type { z } from "zod";
|
|
@@ -16,8 +17,10 @@ import {
|
|
|
16
17
|
SelectTrigger,
|
|
17
18
|
SelectValue,
|
|
18
19
|
} from "@/components/ui/select";
|
|
20
|
+
import { maybeAddMarimoImport } from "@/core/cells/add-missing-import";
|
|
19
21
|
import { useCellActions } from "@/core/cells/cells";
|
|
20
22
|
import { useLastFocusedCellId } from "@/core/cells/focus";
|
|
23
|
+
import { autoInstantiateAtom } from "@/core/config/config";
|
|
21
24
|
import { ENV_RENDERER, SecretsProvider } from "./form-renderers";
|
|
22
25
|
|
|
23
26
|
const RENDERERS: FormRenderer[] = [ENV_RENDERER];
|
|
@@ -106,8 +109,18 @@ export const ConnectionFormFooter = <L extends string>({
|
|
|
106
109
|
export function useInsertCode() {
|
|
107
110
|
const { createNewCell } = useCellActions();
|
|
108
111
|
const lastFocusedCellId = useLastFocusedCellId();
|
|
112
|
+
const autoInstantiate = useAtomValue(autoInstantiateAtom);
|
|
109
113
|
|
|
110
114
|
return (code: string) => {
|
|
115
|
+
// Ensure `mo` is importable when the generated code references it
|
|
116
|
+
if (/\bmo\./.test(code)) {
|
|
117
|
+
maybeAddMarimoImport({
|
|
118
|
+
autoInstantiate,
|
|
119
|
+
createNewCell,
|
|
120
|
+
fromCellId: lastFocusedCellId,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
111
124
|
createNewCell({
|
|
112
125
|
code,
|
|
113
126
|
before: false,
|
package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap
CHANGED
|
@@ -85,14 +85,14 @@ store = GCSStore("my-bucket")"
|
|
|
85
85
|
exports[`generateStorageCode > Google Drive > with default auth (no credentials) 1`] = `
|
|
86
86
|
"from gdrive_fsspec import GoogleDriveFileSystem
|
|
87
87
|
|
|
88
|
-
fs = GoogleDriveFileSystem(use_listings_cache=False)"
|
|
88
|
+
fs = GoogleDriveFileSystem(use_listings_cache=False, skip_instance_cache=True)"
|
|
89
89
|
`;
|
|
90
90
|
|
|
91
91
|
exports[`generateStorageCode > Google Drive > with embedded auth (no credentials) 1`] = `
|
|
92
92
|
"from gdrive_fsspec import GoogleDriveFileSystem
|
|
93
93
|
|
|
94
|
-
fs = GoogleDriveFileSystem(use_listings_cache=False, auth_kwargs={"use_local_webserver": False})
|
|
95
|
-
|
|
94
|
+
fs = GoogleDriveFileSystem(use_listings_cache=False, skip_instance_cache=True, auth_kwargs={"use_local_webserver": False})
|
|
95
|
+
mo.output.clear_console()"
|
|
96
96
|
`;
|
|
97
97
|
|
|
98
98
|
exports[`generateStorageCode > Google Drive > with service account credentials 1`] = `
|
|
@@ -100,7 +100,7 @@ exports[`generateStorageCode > Google Drive > with service account credentials 1
|
|
|
100
100
|
import json
|
|
101
101
|
|
|
102
102
|
_creds = json.loads("""{"type": "service_account", "client_email": "test@test.iam.gserviceaccount.com"}""")
|
|
103
|
-
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False)"
|
|
103
|
+
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False, skip_instance_cache=True)"
|
|
104
104
|
`;
|
|
105
105
|
|
|
106
106
|
exports[`generateStorageCode > S3 > basic connection with all fields 1`] = `
|
|
@@ -168,6 +168,10 @@ function generateGDriveCode(
|
|
|
168
168
|
connection: Extract<StorageConnection, { type: "gdrive" }>,
|
|
169
169
|
options: { secrets: SecretContainer; isEmbedded?: boolean },
|
|
170
170
|
): { imports: Set<string>; code: string } {
|
|
171
|
+
/**
|
|
172
|
+
* Skip instance cache True so you can create multiple connections which don't reference the same creds.
|
|
173
|
+
* Use listings cache False so we don't get stale reads.
|
|
174
|
+
*/
|
|
171
175
|
const { secrets, isEmbedded = false } = options;
|
|
172
176
|
const imports = new Set(["from gdrive_fsspec import GoogleDriveFileSystem"]);
|
|
173
177
|
|
|
@@ -179,18 +183,21 @@ function generateGDriveCode(
|
|
|
179
183
|
);
|
|
180
184
|
const code = dedent(`
|
|
181
185
|
_creds = json.loads("""${connection.credentials_json?.startsWith("ENV:") ? `{${creds}}` : connection.credentials_json}""")
|
|
182
|
-
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False)
|
|
186
|
+
fs = GoogleDriveFileSystem(creds=_creds, token="service_account", use_listings_cache=False, skip_instance_cache=True)
|
|
183
187
|
`);
|
|
184
188
|
return { imports, code };
|
|
185
189
|
}
|
|
186
190
|
|
|
191
|
+
// In the iframe (embedded) flow we authenticate via the console-based OOB
|
|
192
|
+
// flow, which prints an auth URL and reads the code from stdin. Clear the
|
|
193
|
+
// console afterwards so the (single-use) auth code doesn't linger.
|
|
187
194
|
const code = isEmbedded
|
|
188
195
|
? dedent(`
|
|
189
|
-
fs = GoogleDriveFileSystem(use_listings_cache=False, auth_kwargs={"use_local_webserver": False})
|
|
190
|
-
|
|
196
|
+
fs = GoogleDriveFileSystem(use_listings_cache=False, skip_instance_cache=True, auth_kwargs={"use_local_webserver": False})
|
|
197
|
+
mo.output.clear_console()
|
|
191
198
|
`)
|
|
192
199
|
: dedent(`
|
|
193
|
-
fs = GoogleDriveFileSystem(use_listings_cache=False)
|
|
200
|
+
fs = GoogleDriveFileSystem(use_listings_cache=False, skip_instance_cache=True)
|
|
194
201
|
`);
|
|
195
202
|
return { imports, code };
|
|
196
203
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { stripWrappingBackticks } from "../strip-wrapping-backticks";
|
|
5
|
+
|
|
6
|
+
// Cases aligned with `without_wrapping_backticks` on a complete string (single chunk).
|
|
7
|
+
const CASES: [string[], string][] = [
|
|
8
|
+
[["Hello", " world", "!"], "Hello world!"],
|
|
9
|
+
[["```", "print('hello')", "print('world')"], "print('hello')print('world')"],
|
|
10
|
+
[
|
|
11
|
+
["print('hello')", "print('world')", "```"],
|
|
12
|
+
"print('hello')print('world')```",
|
|
13
|
+
],
|
|
14
|
+
[["```", "print('hello')", "```"], "print('hello')"],
|
|
15
|
+
[["Hello", " ``` ", "world"], "Hello ``` world"],
|
|
16
|
+
[["``", "`print('hello')", "``", "`"], "print('hello')"],
|
|
17
|
+
[["``", "`", "\n", "print('hello')", "\n", "``", "`"], "print('hello')"],
|
|
18
|
+
[
|
|
19
|
+
["```\n", "print('hello')", "print('world')", "\n```"],
|
|
20
|
+
"print('hello')print('world')",
|
|
21
|
+
],
|
|
22
|
+
[
|
|
23
|
+
["```\nprint('hello')\n", "print('world')\n```"],
|
|
24
|
+
"print('hello')\nprint('world')",
|
|
25
|
+
],
|
|
26
|
+
[
|
|
27
|
+
["```\n", "def test():\n ", "return True\n```"],
|
|
28
|
+
"def test():\n return True",
|
|
29
|
+
],
|
|
30
|
+
[[], ""],
|
|
31
|
+
[["```idk```"], "idk"],
|
|
32
|
+
[["Hello world"], "Hello world"],
|
|
33
|
+
[
|
|
34
|
+
["```python\n", "def hello():\n ", "print('world')\n```"],
|
|
35
|
+
"def hello():\n print('world')",
|
|
36
|
+
],
|
|
37
|
+
[
|
|
38
|
+
["```python", "\ndef hello():\n ", "print('world')\n```"],
|
|
39
|
+
"def hello():\n print('world')",
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
["```sql", "SELECT * FROM table", " WHERE id = 1", "```"],
|
|
43
|
+
"SELECT * FROM table WHERE id = 1",
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
["```sql\n", "SELECT * FROM table\n", "WHERE id = 1\n```"],
|
|
47
|
+
"SELECT * FROM table\nWHERE id = 1",
|
|
48
|
+
],
|
|
49
|
+
[["```", "print('hello')", "``` "], "print('hello') "],
|
|
50
|
+
[["```python\n", "print('hello')", "\n``` "], "print('hello') "],
|
|
51
|
+
[["```", "code", "```\t\n"], "code\t\n"],
|
|
52
|
+
[[" ```", "code", "```"], "code"],
|
|
53
|
+
[[" ```python\n", "code", "```"], "code"],
|
|
54
|
+
[["\t```", "code", "```"], "code"],
|
|
55
|
+
[["\n", "\n", "```\n", "code", "\n```\n"], "code\n"],
|
|
56
|
+
[["\n", "\n", "```python\n", "code", "\n```\n"], "code\n"],
|
|
57
|
+
[["\n``", "`python\n", "code", "\n```\n"], "code\n"],
|
|
58
|
+
[["\n`", "`", "`python\n", "code", "\n```\n"], "code\n"],
|
|
59
|
+
[["```python ", "code", "```"], " code"],
|
|
60
|
+
[["```python\t", "code", "```"], "\tcode"],
|
|
61
|
+
[["```\n", "```"], ""],
|
|
62
|
+
[["```python\n", "```"], ""],
|
|
63
|
+
[["```\n", "code"], "code"],
|
|
64
|
+
[["```python\n", "code"], "code"],
|
|
65
|
+
[["code", "\n```"], "code\n```"],
|
|
66
|
+
[
|
|
67
|
+
["```\n", "x = 1\n", "```\n", "```\n", "y = 2\n", "```"],
|
|
68
|
+
"x = 1\n```\n```\ny = 2",
|
|
69
|
+
],
|
|
70
|
+
[["```python\n", "s = 'use `backticks`'\n", "```"], "s = 'use `backticks`'"],
|
|
71
|
+
[["``", "`\n", "code\n", "``", "`"], "code"],
|
|
72
|
+
[["```", "python\n", "code\n", "```"], "code"],
|
|
73
|
+
[["```", "code", "```"], "code"],
|
|
74
|
+
[["prefix ", "```\n", "code\n", "```"], "prefix ```\ncode\n```"],
|
|
75
|
+
[["```\n", "code\n", "```", " suffix"], "code\n``` suffix"],
|
|
76
|
+
[["```\n\n", "code\n\n", "```"], "\ncode\n"],
|
|
77
|
+
[["```\n", " code \n", "```"], " code "],
|
|
78
|
+
[["```\n", "\tcode\t\n", "```"], "\tcode\t"],
|
|
79
|
+
[
|
|
80
|
+
["```\n", "x\n", "```\n", "```python\n", "y\n", "```"],
|
|
81
|
+
"x\n```\n```python\ny",
|
|
82
|
+
],
|
|
83
|
+
[["```javascript\n", "console.log()\n", "```"], "javascript\nconsole.log()"],
|
|
84
|
+
[["```markdown\n", "# Title\n", "```"], "# Title"],
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
describe("stripWrappingBackticks", () => {
|
|
88
|
+
it.each(CASES)("strips fences for %j", (chunks, expected) => {
|
|
89
|
+
expect(stripWrappingBackticks(chunks.join(""))).toBe(expected);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// In streaming mode, the opening fence is stripped as soon as it is
|
|
94
|
+
// unambiguous, but the closing fence is left untouched (it may not have
|
|
95
|
+
// arrived yet, or a trailing "```" may be content).
|
|
96
|
+
const STREAMING_CASES: [string, string][] = [
|
|
97
|
+
// No fence: passthrough.
|
|
98
|
+
["import pandas as pd", "import pandas as pd"],
|
|
99
|
+
// Plain opening fence stripped once the first line is terminated.
|
|
100
|
+
["```\n", ""],
|
|
101
|
+
["```\ncode", "code"],
|
|
102
|
+
["```\ncode\nmore", "code\nmore"],
|
|
103
|
+
// Opening fence stripped, closing fence intentionally kept while streaming.
|
|
104
|
+
["```\ncode\n```", "code\n```"],
|
|
105
|
+
["```python\ncode\n```", "code\n```"],
|
|
106
|
+
// Language fences stripped as soon as the full identifier is present.
|
|
107
|
+
["```python", ""],
|
|
108
|
+
["```python\n", ""],
|
|
109
|
+
["```python\ncode", "code"],
|
|
110
|
+
["```sql\nSELECT 1", "SELECT 1"],
|
|
111
|
+
["```markdown\n# Title", "# Title"],
|
|
112
|
+
// Leading whitespace before the fence.
|
|
113
|
+
[" ```python\ncode", "code"],
|
|
114
|
+
// Partial language identifiers are left intact until they resolve.
|
|
115
|
+
["```", "```"],
|
|
116
|
+
["```p", "```p"],
|
|
117
|
+
["```py", "```py"],
|
|
118
|
+
["```pyth", "```pyth"],
|
|
119
|
+
["```s", "```s"],
|
|
120
|
+
["```mark", "```mark"],
|
|
121
|
+
// First-line content that is not a known language prefix is stripped as a
|
|
122
|
+
// plain fence even before a newline arrives.
|
|
123
|
+
["```x", "x"],
|
|
124
|
+
["```x = 1", "x = 1"],
|
|
125
|
+
// Unsupported language identifiers are kept as content (matches final mode).
|
|
126
|
+
["```json\ncode", "json\ncode"],
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
describe("stripWrappingBackticks (streaming)", () => {
|
|
130
|
+
it.each(STREAMING_CASES)("strips opening fence for %j", (text, expected) => {
|
|
131
|
+
expect(stripWrappingBackticks(text, { streaming: true })).toBe(expected);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { consumeStream, parseJsonEventStream, uiMessageChunkSchema } from "ai";
|
|
4
|
+
import { stripWrappingBackticks } from "./strip-wrapping-backticks";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read an AI SDK UI message stream response and return the assistant text.
|
|
8
|
+
*/
|
|
9
|
+
export async function streamCompletionText(
|
|
10
|
+
response: Response,
|
|
11
|
+
): Promise<string> {
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
throw new Error(await response.text());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!response.body) {
|
|
17
|
+
throw new Error("Failed to get response body");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let result = "";
|
|
21
|
+
|
|
22
|
+
await consumeStream({
|
|
23
|
+
stream: parseJsonEventStream({
|
|
24
|
+
stream: response.body,
|
|
25
|
+
schema: uiMessageChunkSchema,
|
|
26
|
+
}).pipeThrough(
|
|
27
|
+
new TransformStream({
|
|
28
|
+
transform(part) {
|
|
29
|
+
if (!part.success) {
|
|
30
|
+
throw part.error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const streamPart = part.value;
|
|
34
|
+
if (streamPart.type === "text-delta") {
|
|
35
|
+
result += streamPart.delta;
|
|
36
|
+
} else if (streamPart.type === "error") {
|
|
37
|
+
throw new Error(streamPart.errorText);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}),
|
|
41
|
+
),
|
|
42
|
+
onError: (error) => {
|
|
43
|
+
throw error;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return stripWrappingBackticks(result);
|
|
48
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
const LANGS = ["python", "sql", "markdown"] as const;
|
|
4
|
+
|
|
5
|
+
interface StripOptions {
|
|
6
|
+
/**
|
|
7
|
+
* When true, the text is treated as a partial stream:
|
|
8
|
+
* - the opening fence is only stripped once we can be sure of its language
|
|
9
|
+
* identifier (e.g. "```py" is left untouched since it may still become
|
|
10
|
+
* "```python"), and
|
|
11
|
+
* - the closing fence is left in place, since it may not have arrived yet (or
|
|
12
|
+
* a trailing "```" may be part of the content).
|
|
13
|
+
*/
|
|
14
|
+
streaming?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Removes wrapping markdown code fences from a completion string.
|
|
19
|
+
*/
|
|
20
|
+
export function stripWrappingBackticks(
|
|
21
|
+
text: string,
|
|
22
|
+
opts: StripOptions = {},
|
|
23
|
+
): string {
|
|
24
|
+
const { streaming = false } = opts;
|
|
25
|
+
const leadingWhitespace = text.match(/^\s*/)?.[0] ?? "";
|
|
26
|
+
const rest = text.slice(leadingWhitespace.length);
|
|
27
|
+
|
|
28
|
+
let body: string | null = null;
|
|
29
|
+
let strippedOpening = false;
|
|
30
|
+
|
|
31
|
+
for (const lang of LANGS) {
|
|
32
|
+
if (rest.startsWith(`\`\`\`${lang}`)) {
|
|
33
|
+
strippedOpening = true;
|
|
34
|
+
body = rest.slice(3 + lang.length);
|
|
35
|
+
if (body.startsWith("\n")) {
|
|
36
|
+
body = body.slice(1);
|
|
37
|
+
}
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!strippedOpening && rest.startsWith("```")) {
|
|
43
|
+
const afterFence = rest.slice(3);
|
|
44
|
+
if (streaming && isPartialLanguageFence(afterFence)) {
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
strippedOpening = true;
|
|
48
|
+
body = afterFence;
|
|
49
|
+
if (body.startsWith("\n")) {
|
|
50
|
+
body = body.slice(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!strippedOpening || body === null) {
|
|
55
|
+
return text;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// While streaming, the closing fence may not have arrived yet, so leave the
|
|
59
|
+
// body as-is once the opening fence is removed.
|
|
60
|
+
if (streaming) {
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const strippedEnd = body.trimEnd();
|
|
65
|
+
const trailingSpace = body.slice(strippedEnd.length);
|
|
66
|
+
|
|
67
|
+
if (strippedEnd.endsWith("\n```")) {
|
|
68
|
+
return strippedEnd.slice(0, -4) + trailingSpace;
|
|
69
|
+
}
|
|
70
|
+
if (strippedEnd.endsWith("```")) {
|
|
71
|
+
return strippedEnd.slice(0, -3) + trailingSpace;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return body;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns true if the text after an opening "```" has no terminating newline
|
|
79
|
+
* yet and could still become a known language identifier.
|
|
80
|
+
*/
|
|
81
|
+
function isPartialLanguageFence(afterFence: string): boolean {
|
|
82
|
+
// Once the first line is terminated, we already know it is not a known
|
|
83
|
+
// language fence (those are handled above), so treat it as a plain fence.
|
|
84
|
+
if (afterFence.includes("\n")) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return LANGS.some((lang) => lang.startsWith(afterFence));
|
|
88
|
+
}
|
|
@@ -2920,6 +2920,39 @@ describe("cell reducer", () => {
|
|
|
2920
2920
|
expect(state.untouchedNewCells.has(newCellId)).toBe(true);
|
|
2921
2921
|
});
|
|
2922
2922
|
|
|
2923
|
+
it("adds interactively-created hidden cell with boilerplate code to untouchedNewCells", () => {
|
|
2924
|
+
// Markdown cells are created with hideCode and non-empty default code
|
|
2925
|
+
// (e.g. `mo.md(r"""\n""")`). They are user-initiated, so their editor
|
|
2926
|
+
// should be shown until first blur.
|
|
2927
|
+
actions.createNewCell({
|
|
2928
|
+
cellId: "__end__",
|
|
2929
|
+
before: false,
|
|
2930
|
+
code: 'mo.md(r"""\n""")',
|
|
2931
|
+
hideCode: true,
|
|
2932
|
+
autoFocus: true,
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
const newCellId =
|
|
2936
|
+
state.cellIds.inOrderIds[state.cellIds.inOrderIds.length - 1];
|
|
2937
|
+
expect(state.untouchedNewCells.has(newCellId)).toBe(true);
|
|
2938
|
+
});
|
|
2939
|
+
|
|
2940
|
+
it("does not add programmatically-created hidden cell with code to untouchedNewCells", () => {
|
|
2941
|
+
// Cells created by the kernel (e.g. via code_mode) carry code and
|
|
2942
|
+
// autoFocus=false; their hide_code must take effect immediately.
|
|
2943
|
+
actions.createNewCell({
|
|
2944
|
+
cellId: "__end__",
|
|
2945
|
+
before: false,
|
|
2946
|
+
code: "x = 1",
|
|
2947
|
+
hideCode: true,
|
|
2948
|
+
autoFocus: false,
|
|
2949
|
+
});
|
|
2950
|
+
|
|
2951
|
+
const newCellId =
|
|
2952
|
+
state.cellIds.inOrderIds[state.cellIds.inOrderIds.length - 1];
|
|
2953
|
+
expect(state.untouchedNewCells.has(newCellId)).toBe(false);
|
|
2954
|
+
});
|
|
2955
|
+
|
|
2923
2956
|
it("does not add cell to untouchedNewCells when hideCode is false", () => {
|
|
2924
2957
|
actions.createNewCell({
|
|
2925
2958
|
cellId: "__end__",
|
package/src/core/cells/cells.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { waitForConnectionOpen } from "@/core/network/connection";
|
|
4
4
|
import type { AiCompletionRequest } from "@/core/network/types";
|
|
5
|
+
import { streamCompletionText } from "@/core/ai/stream-completion-text";
|
|
5
6
|
import { getRuntimeManager } from "@/core/runtime/config";
|
|
6
7
|
import type { LanguageAdapterType } from "../language/types";
|
|
7
8
|
|
|
@@ -47,20 +48,7 @@ ${opts.codeAfter}
|
|
|
47
48
|
|
|
48
49
|
const firstLineIndent = opts.selection.match(/^\s*/)?.[0] || "";
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
if (!reader) {
|
|
52
|
-
throw new Error("Failed to get response reader");
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let result = "";
|
|
56
|
-
// oxlint-disable-next-line no-constant-condition
|
|
57
|
-
while (true) {
|
|
58
|
-
const { done, value } = await reader.read();
|
|
59
|
-
if (done) {
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
result += new TextDecoder().decode(value);
|
|
63
|
-
}
|
|
51
|
+
let result = await streamCompletionText(response);
|
|
64
52
|
|
|
65
53
|
// Add back the indent if it was stripped, which can happen with
|
|
66
54
|
// LLM responses
|