diff --git a/packages/api/ai/srcbook-generator.mts b/packages/api/ai/generate.mts similarity index 73% rename from packages/api/ai/srcbook-generator.mts rename to packages/api/ai/generate.mts index 25c9a052..361510fc 100644 --- a/packages/api/ai/srcbook-generator.mts +++ b/packages/api/ai/generate.mts @@ -2,6 +2,7 @@ import { generateText, GenerateTextResult } from 'ai'; import { type CodeLanguageType, type CellType, + type CodeCellType, randomid, type CellWithPlaceholderType, } from '@srcbook/shared'; @@ -47,6 +48,32 @@ ${query} return prompt; }; +const makeGenerateCellEditSystemPrompt = (language: CodeLanguageType) => { + return readFileSync(Path.join(PROMPTS_DIR, `code-updater-${language}.txt`), 'utf-8'); +}; + +const makeGenerateCellEditUserPrompt = ( + query: string, + session: SessionType, + cell: CodeCellType, +) => { + const inlineSrcbook = encode(session.cells, session.metadata, { inline: true }); + + const prompt = `==== BEGIN SRCBOOK ==== +${inlineSrcbook} +==== END SRCBOOK ==== + +==== BEGIN CODE CELL ==== +${cell.source} +==== END CODE CELL ==== + +==== BEGIN USER REQUEST ==== +${query} +==== END USER REQUEST ==== +`; + return prompt; +}; + /** * Get the OpenAI client and model configuration. * Throws an error if the OpenAI API key is not set in the settings. @@ -90,16 +117,16 @@ export async function generateSrcbook(query: string): Promise { +): Promise { const model = await getOpenAIModel(); const systemPrompt = makeGenerateCellSystemPrompt(session.metadata.language); @@ -116,11 +143,9 @@ export async function generateCell( } // Parse the result into cells - const text = result.text; - - // TODO figure out logging here. It's incredibly valuable to see the data going to and from the LLM - // for debugging, but there are considerations around privacy and log size to think about. - const decodeResult = decodeCells(text); + // TODO: figure out logging. + // Data is incredibly valuable for product improvements, but privacy needs to be considered. + const decodeResult = decodeCells(result.text); if (decodeResult.error) { return { error: true, errors: decodeResult.errors }; @@ -128,3 +153,17 @@ export async function generateCell( return { error: false, cells: decodeResult.cells }; } } + +export async function generateCellEdit(query: string, session: SessionType, cell: CodeCellType) { + const model = await getOpenAIModel(); + + const systemPrompt = makeGenerateCellEditSystemPrompt(session.metadata.language); + const userPrompt = makeGenerateCellEditUserPrompt(query, session, cell); + const result = await generateText({ + model: model, + system: systemPrompt, + prompt: userPrompt, + }); + + return result.text; +} diff --git a/packages/api/prompts/code-updater-javascript.txt b/packages/api/prompts/code-updater-javascript.txt new file mode 100644 index 00000000..4fc79d83 --- /dev/null +++ b/packages/api/prompts/code-updater-javascript.txt @@ -0,0 +1,106 @@ +===== BEGIN INSTRUCTIONS CONTEXT ===== + +You are tasked with editing a code snippet (or "cell") in a Srcbook." + +A Srcbook is a JavaScript notebook following a markdown-compatible format. + +## Srcbook spec + +Structure of a Srcbook: +0. The language comment: `` +1. Title cell (heading 1) +2. Package.json cell, listing deps +3. N more cells, which are either: + a. Markdown cells (GitHub flavored Markdown) + b. javascript code cells, which have a filename and source content. + +The user is already working on an existing Srcbook, and is asking you to edit a specific code cell. +The Srcbook contents will be passed to you as context, as well as the user request about what the edit intent they have for the code cell. +===== END INSTRUCTIONS CONTEXT ====== + +===== BEGIN EXAMPLE SRCBOOK ===== + + +# Getting started + +###### package.json + +```json +{ + "type": "module", + "dependencies": { + "random-words": "^2.0.1" + } +} +``` + +## What are Srcbooks? + +Srcbooks are an interactive way of programming in JavaScript. They are similar to other notebooks like python's [jupyter notebooks](https://jupyter.org/), but unique in their own ways. +They are based on the [node](https://nodejs.org/en) runtime. + +A Srcbook is composed of **cells**. Currently, there are 4 types of cells: + 1. **Title cell**: this is "Getting started" above. There is one per Srcbook. + 2. **package.json cell**: this is a special cell that manages dependencies for the Srcbook. + 3. **markdown cell**: what you're reading is a markdown cell. It allows you to easily express ideas with rich markup, rather than code comments, an idea called [literate programming](https://en.wikipedia.org/wiki/Literate_programming). + 4. **code cell**: think of these as JS or TS files. You can run them or export objects to be used in other cells. + +###### simple-code.js + +```javascript +// This is a trivial code cell. You can run me by +// clicking 'Run' or using the shortcut `cmd` + `enter`. +console.log("Hello, Srcbook!") +``` + +## Dependencies + +You can add any external node.js-compatible dependency from [npm](https://www.npmjs.com/). Let's look at an example below by importing the `random-words` library. + +You'll need to make sure you install dependencies, which you can do by running the `package.json` cell above. + +###### generate-random-word.js + +```javascript +import {generate} from 'random-words'; + +console.log(generate()) +``` + +## Importing other cells + +Behind the scenes, cells are files of JavaScript or TypeScript code. They are ECMAScript 6 modules. Therefore you can export variables from one file and import them in another. + +###### star-wars.js + +```javascript +export const func = (name) => `I am your father, ${name}` +``` + +###### logger.js + +```javascript +import {func} from './star-wars.js'; + +console.log(func("Luke")); +``` + +## Using secrets + +For security purposes, you should avoid pasting secrets directly into Srcbooks. The mechanism you should leverage is [secrets](/secrets). These are stored securely and are accessed at runtime as environment variables. + +Secrets can then be imported in Srcbooks using `process.env.SECRET_NAME`: +``` +const API_KEY = process.env.SECRET_API_KEY; +const token = auth(API_KEY); +``` +===== END EXAMPLE SRCBOOK ===== + +===== BEGIN FINAL INSTRUCTIONS ===== +The user's Srcbook will be passed to you, surrounded with "==== BEGIN SRCBOOK ====" and "==== END SRCBOOK ====". +The specific code cell they want updated will also be passed to you, surrounded with "==== BEGIN CODE CELL ====" and "==== END CODE CELL ====". +The user's intent will be passed to you between "==== BEGIN USER REQUEST ====" and "==== END USER REQUEST ====". +Your job is to edit the cell based on the contents of the Srcbook and the user's intent. +Act as a javascript expert coder, writing the best possible code you can. Focus on being elegant, concise, and clear. +ONLY RETURN THE CODE, NO PREAMBULE, NO BACKTICKS, NO MARKDOWN, NO SUFFIX, ONLY THE JAVASCRIPT CODE. +===== END FINAL INSTRUCTIONS === diff --git a/packages/api/prompts/code-updater-typescript.txt b/packages/api/prompts/code-updater-typescript.txt new file mode 100644 index 00000000..da639b8a --- /dev/null +++ b/packages/api/prompts/code-updater-typescript.txt @@ -0,0 +1,161 @@ +===== BEGIN INSTRUCTIONS CONTEXT ===== + +You are tasked with editing a code snippet (or "cell") in a Srcbook." + +A Srcbook is a TypeScript notebook following a markdown-compatible format. + +## Srcbook spec + +Structure of a Srcbook: +0. The language comment: `` +1. Title cell (heading 1) +2. Package.json cell, listing deps +3. N more cells, which are either: + a. Markdown cells (GitHub flavored Markdown) + b. TypeScript code cells, which have a filename and source content. + +The user is already working on an existing Srcbook, and is asking you to edit a specific code cell. +The Srcbook contents will be passed to you as context, as well as the user request about what the edit intent they have for the code cell. +===== END INSTRUCTIONS CONTEXT ====== + +===== BEGIN EXAMPLE SRCBOOK ===== + + +# Breadth-First Search (BFS) in TypeScript + +###### package.json + +```json +{ + "type": "module", + "dependencies": {}, + "devDependencies": { + "tsx": "latest", + "typescript": "latest", + "@types/node": "^20.14.7" + } +} +``` + +## Introduction to Breadth-First Search (BFS) + +Breadth-First Search (BFS) is an algorithm for traversing or searching tree or graph data structures. It starts at the tree root (or an arbitrary node of a graph) and explores the neighbor nodes at the present depth prior to moving on to nodes at the next depth level. + +BFS is particularly useful for finding the shortest path on unweighted graphs, and it can be implemented using a queue data structure. + +## BFS Algorithm Steps + +1. **Initialize**: Start from the root node and add it to the queue. +2. **Dequeue**: Remove the front node from the queue and mark it as visited. +3. **Enqueue**: Add all unvisited neighbors of the dequeued node to the queue. +4. **Repeat**: Continue the process until the queue is empty. + +## BFS Implementation in TypeScript + +Let's implement BFS in TypeScript. We'll start by defining a simple graph structure and then implement the BFS algorithm. + +###### graph.ts + +```typescript +export class Graph { + private adjacencyList: Map; + + constructor() { + this.adjacencyList = new Map(); + } + + addVertex(vertex: number) { + if (!this.adjacencyList.has(vertex)) { + this.adjacencyList.set(vertex, []); + } + } + + addEdge(vertex1: number, vertex2: number) { + if (this.adjacencyList.has(vertex1) && this.adjacencyList.has(vertex2)) { + this.adjacencyList.get(vertex1)?.push(vertex2); + this.adjacencyList.get(vertex2)?.push(vertex1); + } + } + + getNeighbors(vertex: number): number[] { + return this.adjacencyList.get(vertex) || []; + } +} +``` + +In the above code, we define a `Graph` class with methods to add vertices and edges, and to retrieve the neighbors of a vertex. + +Next, let's implement the BFS algorithm. + +###### bfs.ts + +```typescript +import { Graph } from './graph.ts'; + +export function bfs(graph: Graph, startVertex: number): number[] { + const visited: Set = new Set(); + const queue: number[] = [startVertex]; + const result: number[] = []; + + while (queue.length > 0) { + const vertex = queue.shift()!; + if (!visited.has(vertex)) { + visited.add(vertex); + result.push(vertex); + + const neighbors = graph.getNeighbors(vertex); + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + queue.push(neighbor); + } + } + } + } + + return result; +} +``` + +In the `bfs` function, we use a queue to keep track of the vertices to be explored and a set to keep track of visited vertices. The function returns the order in which the vertices are visited. + +## Example Usage + +Let's create a graph and perform BFS on it. + +###### example.ts + +```typescript +import { Graph } from './graph.ts'; +import { bfs } from './bfs.ts'; + +const graph = new Graph(); +graph.addVertex(1); +graph.addVertex(2); +graph.addVertex(3); +graph.addVertex(4); +graph.addVertex(5); + +graph.addEdge(1, 2); +graph.addEdge(1, 3); +graph.addEdge(2, 4); +graph.addEdge(3, 5); + +const bfsResult = bfs(graph, 1); +console.log('BFS Traversal Order:', bfsResult); +``` + +In this example, we create a graph with 5 vertices and add edges between them. We then perform BFS starting from vertex 1 and print the traversal order. + +## Conclusion + +Breadth-First Search (BFS) is a fundamental algorithm for graph traversal. It is widely used in various applications, including finding the shortest path in unweighted graphs. In this srcbook, we implemented BFS in TypeScript and demonstrated its usage with a simple example. +===== END EXAMPLE SRCBOOK ===== + +===== BEGIN FINAL INSTRUCTIONS ===== +The user's Srcbook will be passed to you, surrounded with "==== BEGIN SRCBOOK ====" and "==== END SRCBOOK ====". +The specific code cell they want updated will also be passed to you, surrounded with "==== BEGIN CODE CELL ====" and "==== END CODE CELL ====". +The user's intent will be passed to you between "==== BEGIN USER REQUEST ====" and "==== END USER REQUEST ====". +Your job is to edit the cell based on the contents of the Srcbook and the user's intent. +Act as a TypeScript expert coder, writing the best possible code you can. Focus on being elegant, concise, and clear. +ONLY RETURN THE CODE, NO PREAMBULE, NO BACKTICKS, NO MARKDOWN, NO SUFFIX, ONLY THE TYPESCRIPT CODE. +===== END FINAL INSTRUCTIONS === diff --git a/packages/api/server/http.mts b/packages/api/server/http.mts index 76cdf44c..795ae3da 100644 --- a/packages/api/server/http.mts +++ b/packages/api/server/http.mts @@ -13,7 +13,7 @@ import { sessionToResponse, listSessions, } from '../session.mjs'; -import { generateCell, generateSrcbook } from '../ai/srcbook-generator.mjs'; +import { generateCells, generateSrcbook } from '../ai/generate.mjs'; import { disk } from '../utils.mjs'; import { getConfig, updateConfig, getSecrets, addSecret, removeSecret } from '../config.mjs'; import { @@ -138,7 +138,7 @@ router.post('/sessions/:id/generate_cells', cors(), async (req, res) => { try { posthog.capture({ event: 'user generated cell with AI', properties: { query } }); const session = await findSession(req.params.id); - const { error, errors, cells } = await generateCell(query, session, insertIdx); + const { error, errors, cells } = await generateCells(query, session, insertIdx); const result = error ? errors : cells; return res.json({ error, result }); } catch (e) { diff --git a/packages/api/server/ws.mts b/packages/api/server/ws.mts index 9cd659cf..197d4f89 100644 --- a/packages/api/server/ws.mts +++ b/packages/api/server/ws.mts @@ -1,4 +1,5 @@ import { ChildProcess } from 'node:child_process'; +import { generateCellEdit } from '../ai/generate.mjs'; import { findSession, findCell, @@ -29,6 +30,7 @@ import type { CellRenamePayloadType, CellErrorType, CellCreatePayloadType, + AiGenerateCellPayloadType, } from '@srcbook/shared'; import { CellErrorPayloadSchema, @@ -38,6 +40,8 @@ import { CellDeletePayloadSchema, CellExecPayloadSchema, CellStopPayloadSchema, + AiGenerateCellPayloadSchema, + AiGeneratedCellPayloadSchema, DepsInstallPayloadSchema, DepsValidatePayloadSchema, CellOutputPayloadSchema, @@ -347,6 +351,17 @@ function reopenFileInTsServer( tsserver.open({ file: openFilePath, fileContent: file.source }); } +async function cellGenerate(payload: AiGenerateCellPayloadType) { + const session = await findSession(payload.sessionId); + const cell = session.cells.find((cell) => cell.id === payload.cellId) as CodeCellType; + + const result = await generateCellEdit(payload.prompt, session, cell); + + wss.broadcast(`session:${session.id}`, 'ai:generated', { + cellId: payload.cellId, + output: result, + }); +} async function cellUpdate(payload: CellUpdatePayloadType) { const session = await findSession(payload.sessionId); @@ -569,6 +584,7 @@ wss .incoming('cell:update', CellUpdatePayloadSchema, cellUpdate) .incoming('cell:rename', CellRenamePayloadSchema, cellRename) .incoming('cell:delete', CellDeletePayloadSchema, cellDelete) + .incoming('ai:generate', AiGenerateCellPayloadSchema, cellGenerate) .incoming('deps:install', DepsInstallPayloadSchema, depsInstall) .incoming('deps:validate', DepsValidatePayloadSchema, depsValidate) .incoming('tsserver:start', TsServerStartPayloadSchema, tsserverStart) @@ -576,6 +592,7 @@ wss .outgoing('cell:updated', CellUpdatedPayloadSchema) .outgoing('cell:error', CellErrorPayloadSchema) .outgoing('cell:output', CellOutputPayloadSchema) + .outgoing('ai:generated', AiGeneratedCellPayloadSchema) .outgoing('deps:validate:response', DepsValidateResponsePayloadSchema) .outgoing('tsserver:cell:diagnostics', TsServerCellDiagnosticsPayloadSchema); diff --git a/packages/shared/src/schemas/websockets.ts b/packages/shared/src/schemas/websockets.ts index ae8e8c67..d5938999 100644 --- a/packages/shared/src/schemas/websockets.ts +++ b/packages/shared/src/schemas/websockets.ts @@ -24,6 +24,12 @@ export const CellUpdatePayloadSchema = z.object({ updates: CellUpdateAttrsSchema, }); +export const AiGenerateCellPayloadSchema = z.object({ + sessionId: z.string(), + cellId: z.string(), + prompt: z.string(), +}); + export const CellRenamePayloadSchema = z.object({ sessionId: z.string(), cellId: z.string(), @@ -50,6 +56,11 @@ export const CellUpdatedPayloadSchema = z.object({ cell: CellSchema, }); +export const AiGeneratedCellPayloadSchema = z.object({ + cellId: z.string(), + output: z.string(), +}); + export const CellOutputPayloadSchema = z.object({ cellId: z.string(), output: z.object({ diff --git a/packages/shared/src/types/websockets.ts b/packages/shared/src/types/websockets.ts index 68976cab..ebe4c481 100644 --- a/packages/shared/src/types/websockets.ts +++ b/packages/shared/src/types/websockets.ts @@ -8,6 +8,8 @@ import { CellUpdatedPayloadSchema, CellRenamePayloadSchema, CellDeletePayloadSchema, + AiGenerateCellPayloadSchema, + AiGeneratedCellPayloadSchema, CellOutputPayloadSchema, DepsInstallPayloadSchema, DepsValidateResponsePayloadSchema, @@ -26,6 +28,8 @@ export type CellUpdatedPayloadType = z.infer; export type CellRenamePayloadType = z.infer; export type CellDeletePayloadType = z.infer; export type CellOutputPayloadType = z.infer; +export type AiGenerateCellPayloadType = z.infer; +export type AiGeneratedCellPayloadType = z.infer; export type DepsInstallPayloadType = z.infer; export type DepsValidateResponsePayloadType = z.infer; diff --git a/packages/web/package.json b/packages/web/package.json index 645dd235..e217c462 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -16,6 +16,8 @@ "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-markdown": "^6.2.5", + "@codemirror/merge": "^6.6.5", + "@codemirror/state": "^6.4.1", "@lezer/highlight": "^1.2.0", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.5", @@ -32,6 +34,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "codemirror": "^6.0.1", "localforage": "^1.10.0", "lucide-react": "^0.378.0", "marked": "^12.0.2", @@ -42,6 +45,7 @@ "react-dropzone": "^14.2.3", "react-hotkeys-hook": "^4.5.0", "react-router-dom": "^6.23.0", + "react-textarea-autosize": "^8.5.3", "sonner": "^1.4.41", "sort-by": "^1.2.0", "tailwind-merge": "^2.3.0", diff --git a/packages/web/src/clients/websocket/index.ts b/packages/web/src/clients/websocket/index.ts index 20db6b12..903be782 100644 --- a/packages/web/src/clients/websocket/index.ts +++ b/packages/web/src/clients/websocket/index.ts @@ -1,6 +1,8 @@ import { CellOutputPayloadSchema, CellCreatePayloadSchema, + AiGenerateCellPayloadSchema, + AiGeneratedCellPayloadSchema, CellUpdatedPayloadSchema, DepsValidateResponsePayloadSchema, CellExecPayloadSchema, @@ -29,6 +31,7 @@ const IncomingSessionEvents = { 'cell:updated': CellUpdatedPayloadSchema, 'deps:validate:response': DepsValidateResponsePayloadSchema, 'tsserver:cell:diagnostics': TsServerCellDiagnosticsPayloadSchema, + 'ai:generated': AiGeneratedCellPayloadSchema, }; const OutgoingSessionEvents = { @@ -38,6 +41,7 @@ const OutgoingSessionEvents = { 'cell:update': CellUpdatePayloadSchema, 'cell:rename': CellRenamePayloadSchema, 'cell:delete': CellDeletePayloadSchema, + 'ai:generate': AiGenerateCellPayloadSchema, 'deps:install': DepsInstallPayloadSchema, 'deps:validate': DepsValidatePayloadSchema, 'tsserver:start': TsServerStartPayloadSchema, diff --git a/packages/web/src/components/ai-generate-tips-dialog.tsx b/packages/web/src/components/ai-generate-tips-dialog.tsx new file mode 100644 index 00000000..eb051b2f --- /dev/null +++ b/packages/web/src/components/ai-generate-tips-dialog.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export default function AiGenerateTipsDialog({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false); + return ( + + {children} + + + Prompt tips +
+

Here are a few tips to get the AI to work well for you.

+
    +
  • The AI knows already knows about all of the contents of this srcbook.
  • +
  • It also knows what cell you're updating.
  • +
  • You can ask the code to add or improve comments or jsdoc.
  • +
  • You can ask the AI to refactor or rewrite the whole thing.
  • +
  • + Try asking the AI to refactor, improve or modularize your code, simply by asking for + it. +
  • +
+
+
+
+
+ ); +} diff --git a/packages/web/src/components/cells/code.tsx b/packages/web/src/components/cells/code.tsx index 309cc11f..d17c9044 100644 --- a/packages/web/src/components/cells/code.tsx +++ b/packages/web/src/components/cells/code.tsx @@ -1,12 +1,16 @@ import { useEffect, useRef, useState } from 'react'; +import { useHotkeys } from 'react-hotkeys-hook'; import CodeMirror, { keymap, Prec } from '@uiw/react-codemirror'; import { javascript } from '@codemirror/lang-javascript'; -import { Circle, Info, Play, Trash2 } from 'lucide-react'; +import { Circle, Info, Play, Trash2, Sparkles, X, MessageCircleWarning } from 'lucide-react'; +import TextareaAutosize from 'react-textarea-autosize'; +import AiGenerateTipsDialog from '@/components/ai-generate-tips-dialog'; import { CellType, CodeCellType, CodeCellUpdateAttrsType, CellErrorPayloadType, + AiGeneratedCellPayloadType, } from '@srcbook/shared'; import { cn } from '@/lib/utils'; import { SessionType } from '@/types'; @@ -18,6 +22,9 @@ import { useCells } from '@/components/use-cell'; import { CellOutput } from '@/components/cell-output'; import useTheme from '@/components/use-theme'; import { useDebouncedCallback } from 'use-debounce'; +import { EditorView } from 'codemirror'; +import { EditorState } from '@codemirror/state'; +import { unifiedMergeView } from '@codemirror/merge'; const DEBOUNCE_DELAY = 500; @@ -31,6 +38,19 @@ export default function CodeCell(props: { const { session, cell, channel, onUpdateCell, onDeleteCell } = props; const [filenameError, _setFilenameError] = useState(null); const [showStdio, setShowStdio] = useState(false); + const [promptMode, setPromptMode] = useState<'off' | 'generating' | 'reviewing' | 'idle'>('off'); + const [prompt, setPrompt] = useState(''); + const [newSource, setNewSource] = useState(''); + + useHotkeys( + 'mod+enter', + () => { + if (!prompt) return; + if (promptMode !== 'idle') return; + generate(); + }, + { enableOnFormTags: ['textarea'] }, + ); const { updateCell, clearOutput } = useCells(); @@ -66,6 +86,26 @@ export default function CodeCell(props: { }); } + useEffect(() => { + function callback(payload: AiGeneratedCellPayloadType) { + if (payload.cellId !== cell.id) return; + // We move to the "review" stage of the generation process: + setNewSource(payload.output); + setPromptMode('reviewing'); + } + channel.on('ai:generated', callback); + return () => channel.off('ai:generated', callback); + }, [cell.id, channel]); + + const generate = () => { + channel.push('ai:generate', { + sessionId: session.id, + cellId: cell.id, + prompt, + }); + setPromptMode('generating'); + }; + function runCell() { if (cell.status === 'running') { return false; @@ -91,24 +131,35 @@ export default function CodeCell(props: { channel.push('cell:stop', { sessionId: session.id, cellId: cell.id }); } + function onRevertDiff() { + setPromptMode('idle'); + setNewSource(''); + } + + async function onAcceptDiff() { + await onUpdateCell(cell, { source: newSource }); + setPrompt(''); + setPromptMode('off'); + } + return (
-
+
setFilenameError(null)} className={cn( - 'w-[200px] font-mono font-semibold text-xs transition-colors', + 'w-[200px] font-mono font-semibold text-xs transition-colors px-2', filenameError ? 'border-error' : 'border-transparent hover:border-input group-hover:border-input ', @@ -133,21 +184,94 @@ export default function CodeCell(props: { cell.status === 'running' ? 'opacity-100' : '', )} > - {cell.status === 'running' && ( - + {promptMode === 'idle' && ( + )} - {cell.status === 'idle' && ( - )} + {promptMode === 'off' && ( + <> + {cell.status === 'running' && ( + + )} + {cell.status === 'idle' && ( + + )} + + )}
- - + {promptMode !== 'off' && ( +
+
+ + setPrompt(e.target.value)} + /> +
+
+ + + + +
+
+ )} + + {promptMode === 'reviewing' ? ( + + ) : ( + <> +
+ +
+ + + )}
); @@ -157,10 +281,12 @@ function CodeEditor({ cell, runCell, onUpdateCell, + readOnly, }: { cell: CodeCellType; runCell: () => void; onUpdateCell: (cell: CodeCellType, attrs: CodeCellUpdateAttrsType) => Promise; + readOnly: boolean; }) { const { codeTheme } = useTheme(); const { updateCell: updateCellClientSideOnly } = useCells(); @@ -172,14 +298,19 @@ function CodeEditor({ return true; } + let extensions = [ + javascript({ typescript: true }), + Prec.highest(keymap.of([{ key: 'Mod-Enter', run: evaluateModEnter }])), + ]; + if (readOnly) { + extensions = extensions.concat([EditorView.editable.of(false), EditorState.readOnly.of(true)]); + } + return ( { updateCellClientSideOnly({ ...cell, source }); onUpdateCellDebounced(cell, { source }); @@ -188,6 +319,44 @@ function CodeEditor({ ); } +function DiffEditor({ + original, + modified, + onAccept, + onRevert, +}: { + original: string; + modified: string; + onAccept: () => void; + onRevert: () => void; +}) { + const { codeTheme } = useTheme(); + + return ( +
+ +
+ + +
+
+ ); +} + function FilenameInput(props: { filename: string; className: string; diff --git a/packages/web/src/index.css b/packages/web/src/index.css index 4a9f2328..726cefb4 100644 --- a/packages/web/src/index.css +++ b/packages/web/src/index.css @@ -350,6 +350,10 @@ min-height: '100px'; } +.cm-editor del { + text-decoration: none; +} + .cm-editor * { font-family: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', 'monospace'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b71449c8..879223d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,12 @@ importers: '@codemirror/lang-markdown': specifier: ^6.2.5 version: 6.2.5 + '@codemirror/merge': + specifier: ^6.6.5 + version: 6.6.5 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.4.1 '@lezer/highlight': specifier: ^1.2.0 version: 1.2.0 @@ -157,6 +163,9 @@ importers: cmdk: specifier: ^1.0.0 version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + codemirror: + specifier: ^6.0.1 + version: 6.0.1(@lezer/common@1.2.1) localforage: specifier: ^1.10.0 version: 1.10.0 @@ -187,6 +196,9 @@ importers: react-router-dom: specifier: ^6.23.0 version: 6.23.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-textarea-autosize: + specifier: ^8.5.3 + version: 8.5.3(@types/react@18.3.3)(react@18.3.1) sonner: specifier: ^1.4.41 version: 1.4.41(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -458,6 +470,9 @@ packages: '@codemirror/lint@6.8.0': resolution: {integrity: sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==} + '@codemirror/merge@6.6.5': + resolution: {integrity: sha512-jyM/20pb5BaDkFBmobmCq17Gb1ioUkmCrVoBxXv3fvts7TxThHg+vriz1dBqunIr5+ukVN++wHyWWSU13MYJKA==} + '@codemirror/search@6.5.6': resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==} @@ -920,6 +935,7 @@ packages: '@humanwhocodes/config-array@0.11.14': resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -927,6 +943,7 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} @@ -3440,6 +3457,12 @@ packages: '@types/react': optional: true + react-textarea-autosize@8.5.3: + resolution: {integrity: sha512-XT1024o2pqCuZSuBt9FwHlaDeNtVrtCXu0Rnz88t1jUGheCLa3PhjE1GH8Ctm2axEtvdCl5SUHYschyQ0L5QHQ==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -3805,12 +3828,35 @@ packages: '@types/react': optional: true + use-composed-ref@1.3.0: + resolution: {integrity: sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + use-debounce@10.0.1: resolution: {integrity: sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg==} engines: {node: '>= 16.0.0'} peerDependencies: react: '>=16.8.0' + use-isomorphic-layout-effect@1.1.2: + resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.2.1: + resolution: {integrity: sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + use-sidecar@1.1.2: resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} engines: {node: '>=10'} @@ -4218,6 +4264,14 @@ snapshots: '@codemirror/view': 6.27.0 crelt: 1.0.6 + '@codemirror/merge@6.6.5': + dependencies: + '@codemirror/language': 6.10.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.27.0 + '@lezer/highlight': 1.2.0 + style-mod: 4.1.2 + '@codemirror/search@6.5.6': dependencies: '@codemirror/state': 6.4.1 @@ -7079,6 +7133,15 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + react-textarea-autosize@8.5.3(@types/react@18.3.3)(react@18.3.1): + dependencies: + '@babel/runtime': 7.24.7 + react: 18.3.1 + use-composed-ref: 1.3.0(react@18.3.1) + use-latest: 1.2.1(@types/react@18.3.3)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + react@18.3.1: dependencies: loose-envify: 1.4.0 @@ -7482,10 +7545,27 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + use-composed-ref@1.3.0(react@18.3.1): + dependencies: + react: 18.3.1 + use-debounce@10.0.1(react@18.3.1): dependencies: react: 18.3.1 + use-isomorphic-layout-effect@1.1.2(@types/react@18.3.3)(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.3 + + use-latest@1.2.1(@types/react@18.3.3)(react@18.3.1): + dependencies: + react: 18.3.1 + use-isomorphic-layout-effect: 1.1.2(@types/react@18.3.3)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.3 + use-sidecar@1.1.2(@types/react@18.3.3)(react@18.3.1): dependencies: detect-node-es: 1.1.0