diff --git a/README.md b/README.md index 297f2806..f78f7ece 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ needs to be useful: | πŸ–₯️ **Computer use** | Share a single window, a display, the entire desktop (every monitor) or a browser tab with an agent β€” like sharing your screen with ChatGPT. A shared window is controlled **in the background** with [Cua Driver](https://github.com/trycua/cua): your mouse and keyboard stay yours. Watch live and take over anytime. | | πŸ’» **macOS VMs** | Give an agent its own Mac: spin up isolated macOS virtual machines (Apple's Virtualization framework, via [Tart](https://tart.run)) with one click and assign them to an agent, a chat or a workspace. The agent works *entirely inside the VM* β€” commands, files, apps (computer use with Cua Driver) and the web (Google Chrome with browser-use) β€” and no browser opens on your Mac. It gives the software it installs there the macOS permissions it needs (Accessibility, Screen Recording, Automation, …) itself, so no permission dialog stops it. Watch the VM's screen next to the chat and take control anytime. Allow it once and agents sign in inside the VM too: Godmode fills your saved logins and 2FA codes for them (best effort β€” the agent controls the VM, so it's closer to reveal than to fill-only). VMs live on your Mac, keep everything between tasks, suspend when you quit, and can be reset to a clean macOS or duplicated in seconds. | | πŸ”Œ **SSH servers** | Let agents work on your servers: save a server with a password or an SSH key (paste it, pick one from `~/.ssh` or generate a new one) and give it to a chat or an agent. Godmode signs in and answers `sudo` β€” the AI never sees the password or the key. Agents run commands, edit config files and copy files back and forth; the host key is pinned on the first connection. | +| 🧩 **Mods** | [Claude Code mods](https://claude.com/blog/claude-code-mods) for your agents: small pieces of code that run inside every turn β€” refuse risky shell commands, keep agents away from `.env` files, mask keys in tool output before the model reads them, cap runaway turns, expand `!shortcuts`. Add one from the gallery, set its options, pick the agents it runs for; write your own in the built-in editor or let Godmode draft it. Every mod is checked by Claude Code's own validator, and Godmode shows what it hooks into and what it can reach before you switch it on. | | πŸͺ **Chrome session import** | Continue where Chrome left off β€” import cookies from your Chrome/Edge/Brave profile (profile-use technique), or sync via browser-use `profile-use`. | | πŸ” **Vault** | Logins with password generator, per-workspace or global, AES-256-GCM encrypted, fully audited. | | πŸ“₯ **Password import** | Bring logins over from Chrome (and Edge, Brave, Arc), 1Password (.1pux or CSV), Bitwarden, Apple Passwords, Firefox and more β€” with a preview that updates saved logins instead of duplicating them. | @@ -176,6 +177,18 @@ needs to be useful: SSH servers with their connection status, pinned host keys and the agents and chats using them
SSH servers β€” password or key, sealed in the vault; host keys pinned on the first connection An agent reading a config and a log on a server over SSH and changing the config after backing it up
Work on servers β€” pick servers for a chat; the agent runs commands and edits files there + + The Mods page: installed mods with what they hook into and the agents they run for, and the gallery below
Mods β€” what each one hooks into, what it can reach and whose runs load it + A mod an agent drafted: its code in the editor, marked for review, with Claude Code's check result underneath
Read and edit the code β€” a mod Godmode drafted waits for your review; Claude Code's validator checks every change + + + The options of Protect files: the protected paths and the kind of protection
Options β€” set what a mod protects or refuses without touching its code + A chat where Secret scrubber masked a token, Command guard and Protect files refused two steps and Turn recap summed up the turn
In the chat β€” mods refuse steps, mask secrets and post notes while the agent works + + + The mod gallery: Protect files, Command guard, Step limit, Secret scrubber, Turn recap and Prompt shortcuts
Gallery β€” guardrails, privacy, insight and workflow mods that work as they are + A mod's overview: what it can do, with talking to the internet marked as reaching outside the conversation, and the event it hooks
What it can do β€” told from the code by Claude Code's validator, before you switch it on + Settings β†’ Cleanup: storage by area, a self check of tools, permissions, updates, database, disk and worktrees
Cleanup β€” what the data folder holds, and a self check of everything Godmode relies on What can go: leftovers, browser caches, worktrees of finished tasks, unused clones, database, logs, trash
Free up space β€” safe items are picked for you; whatever may still hold work stays @@ -345,6 +358,30 @@ trusts only that key from then on. Then pick the server for a **chat** (the *SSH | **Secrets** | The password, key and passphrase are sealed in the vault and never part of the prompt; transcripts and tool results mask them, also when a command prints them. The agent works in that account's shell, so give it an account with only the rights it needs β€” a narrow `NOPASSWD` sudo rule is safer than a saved sudo password. | | **Checking in** | Each server card shows whether it was reachable, its OS and pinned host key, which agents and chats use it, and a **Run command** box for a quick look yourself. | +### Mods β€” change how your agents work + +Open **Mods** in the sidebar. The **gallery** has mods that are ready to use β€” add one and it is on for every agent: + +| | | +|---|---| +| **Protect files** | Agents can't edit or overwrite the paths you name (`.env`, keys, a `secrets` folder), also not with a shell command that redirects into them or removes, moves or edits them β€” or, if you choose, can't read them or name them in a shell command at all. | +| **Command guard** | Refuses shell commands that match a pattern: force pushes, `git reset --hard`, `rm -rf` on your home folder, piping a download into a shell β€” on this computer and in the shells of servers and virtual machines. The agent is told to explain instead of finding another way. | +| **Step limit** | Caps the tool calls of one turn; past the limit the agent has to wrap up and say what is left. | +| **Secret scrubber** | Masks AWS, GitHub, Stripe, Slack and Google keys, JWTs, private keys, bearer tokens, passwords in URLs and `password=…` values in tool output before the model reads it β€” also secrets that aren't in your vault. Code is left as it is. | +| **Turn recap** | Posts a line after each long turn: duration, tools used, failed calls. | +| **Prompt shortcuts** | `!brief`, `!plan` and shortcuts of your own, at the start of a line or as the last word, are expanded before the agent reads your message. | + +Each mod has **options** (the paths, the patterns, the limit), and **Runs for** decides whose runs load it: every agent +or the ones you pick. For anything else, **New mod** opens an editor with a starting point, or **Ask Godmode to write +one** and review its draft: a mod an agent wrote arrives switched off, and switching it on is your OK β€” for exactly +the code you read. + +A mod is real code inside the agent's process, so Godmode shows what it is before it runs: Claude Code's validator +checks every change, and the mod's page lists what it **hooks into** (Bash calls, prompts, …) and what it can **reach +outside the conversation** (files, programs, the network). A mod that doesn't pass can't be switched on; one that stops +passing after a Claude Code update is left out of runs and the chat says so. What a mod posts (`$.ui.log`, a toast, a +status) shows in the chat as a note from that mod. + ### Good to know - **macOS β€” Chrome session import** reads your Chrome profile, which macOS protects: grant Godmode @@ -473,6 +510,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). - [x] Agents working in a dedicated macOS VM (Tart / Virtualization.framework): shell, files and screen, assigned per agent, chat or workspace - [x] API tools: any API with a key (Nano Banana, OpenAI, ElevenLabs…) for agents, global / workspace / agent - [x] SSH servers: agents run commands, edit files and copy files on remote machines β€” password or key, sudo, pinned host keys +- [x] Mods: Claude Code mods for agents β€” a gallery of guardrails, options, an editor, drafts written by Godmode, checked before they run - [ ] Windows / Linux VMs - [x] Phone app (iOS / Android): chats, runs, automations, live browser, screen and VM views, paired over Tailscale - [x] Runners: another Mac does the work of a chat while this one sleeps β€” one install command, encrypted link, setup copied, live view diff --git a/SECURITY.md b/SECURITY.md index 979417cc..29debee9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -65,6 +65,18 @@ We aim to acknowledge reports within 72 hours and to ship a fix for critical iss Godmode's own windows and dashboard tabs can't be shared. Unattended desktop access for routines is a human-only agent setting; agents without it can't hand work to agents that have it, and backups never restore it. Shares and their first use per run are audited (`computer.share`, `computer.unshare`, `computer.control`). +- **Mods are code you switch on**: a mod runs inside Claude Code in every turn of the agents it is for and can reach + whatever its code asks for, so only you switch one on β€” and the switch counts for the code you saw: if it changed + meanwhile, Godmode asks you to read it again. Claude Code's validator checks every change; the mod's page lists what + it hooks and what it reaches outside the conversation (files, programs, the network, environment variables). A mod an + agent wrote arrives switched off and marked for review, an agent can only save over its own drafts β€” never a mod you + made, added or have had on β€” and a backup brings mods back switched off. Godmode keeps a mod's files in its database + and gives every run a copy of its own, so what one run does to its mods reaches no other run. Secret options are + sealed in the vault; a run that loads the mod is handed them, where an agent with full access to your computer could + read them β€” use a key made for the mod. The gallery's guardrails (*Protect files*, *Command guard*, *Secret + scrubber*) match patterns in tool calls and output: they catch mistakes and the obvious cases, not an agent that is + set on getting around them (a run with full permissions can rewrite its own copy of a guard) β€” isolation is what VMs + and permissions are for. - **SSH servers are assigned by you**: an agent only reaches the servers you give its chat or the agent itself β€” agents can't assign servers to themselves or others, and delegated work doesn't inherit a chat's servers. Godmode signs in with the password or key sealed in the vault; they are never part of the prompt, and tool results mask them (the diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 5ee3b723..1bd4251b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -23,6 +23,7 @@ const AgentsPage = lazy(() => import("@/pages/agents/agents-page")); const AgentNewPage = lazy(() => import("@/pages/agents/agent-new")); const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail")); const AutomationsPage = lazy(() => import("@/pages/automations/automations-page")); +const ModsPage = lazy(() => import("@/pages/mods/mods-page")); const ActivityPage = lazy(() => import("@/pages/activity/activity-page")); const WorkspacesPage = lazy(() => import("@/pages/workspaces/workspaces-page")); const TasksPage = lazy(() => import("@/pages/tasks/tasks-page")); @@ -183,6 +184,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/chat/message-blocks.tsx b/apps/desktop/src/components/chat/message-blocks.tsx index 2d0d9363..19abe846 100644 --- a/apps/desktop/src/components/chat/message-blocks.tsx +++ b/apps/desktop/src/components/chat/message-blocks.tsx @@ -6,7 +6,7 @@ import { format } from "date-fns"; import { AnimatePresence, motion } from "motion/react"; import type { Agent, Credential, MessageBlock, ToolTaskAgent } from "@godmode/shared"; import { WORKFLOW_TOOL } from "@godmode/shared"; -import { ArrowUpRight, Brain, CheckCircle2, ChevronRight, Circle, CircleDot, CornerDownRight, Info, Loader2, Lock, ShieldAlert, Square, SquareSlash, TriangleAlert, Workflow, XCircle } from "lucide-react"; +import { ArrowUpRight, Brain, CheckCircle2, ChevronRight, Circle, CircleDot, CornerDownRight, Info, Loader2, Lock, Puzzle, ShieldAlert, Square, SquareSlash, TriangleAlert, Workflow, XCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AgentAvatar } from "@/components/common"; import { ThinkingState } from "@/components/aicss/ThinkingState"; @@ -17,7 +17,7 @@ import { Orb } from "@/components/aicss/Orb"; import { formatDuration, formatTokens } from "@/components/runs/run-status"; import { api, errorMessage } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; -import { useAllAgents } from "@/lib/hooks"; +import { useAllAgents, useMods } from "@/lib/hooks"; import { cn } from "@/lib/utils"; import { useLive } from "@/stores/live"; import { Markdown } from "./markdown"; @@ -40,7 +40,7 @@ type Item = | { kind: "text"; key: string; text: string } | { kind: "thinking"; key: string; text: string } | { kind: "error"; key: string; text: string } - | { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string } + | { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string; mod?: string } | { kind: "command"; key: string; name: string; args: string; output: string } | { kind: "user-message"; key: string; block: UserMessageBlock } | { kind: "pause"; key: string; block: PauseBlock } @@ -120,7 +120,7 @@ function buildItems(blocks: MessageBlock[]): Item[] { if (b.type === "text") { if (b.text.trim()) items.push({ kind: "text", key, text: b.text }); } else if (b.type === "error") items.push({ kind: "error", key, text: b.text }); - else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text }); + else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text, mod: b.mod }); else if (b.type === "command") items.push({ kind: "command", key, name: b.name, args: b.args, output: b.output }); else if (b.type === "user_message") items.push({ kind: "user-message", key: b.id, block: b }); // A run that stands still for a question: the card says so. @@ -194,7 +194,11 @@ export function MessageBlocks({ ); case "notice": - return ; + return item.mod ? ( + + ) : ( + + ); case "command": return ; case "user-message": @@ -252,12 +256,14 @@ function PickedUpMessage({ block }: { block: UserMessageBlock }) { ); } +const NOTICE = { + info: { icon: Info, cls: "border-border bg-card text-muted-foreground" }, + warning: { icon: TriangleAlert, cls: "border-warning/30 bg-warning/[0.07] text-warning" }, + success: { icon: CheckCircle2, cls: "border-success/25 bg-success/[0.07] text-success" }, +} as const; + function NoticeItem({ level, text }: { level: "info" | "warning" | "success"; text: string }) { - const meta = { - info: { icon: Info, cls: "border-border bg-card text-muted-foreground" }, - warning: { icon: TriangleAlert, cls: "border-warning/30 bg-warning/[0.07] text-warning" }, - success: { icon: CheckCircle2, cls: "border-success/25 bg-success/[0.07] text-success" }, - }[level]; + const meta = NOTICE[level]; const Icon = meta.icon; return (
@@ -267,6 +273,37 @@ function NoticeItem({ level, text }: { level: "info" | "warning" | "success"; te ); } +/** A note a Claude Code mod posted: who says it comes first, as a chip that leads to the mod. */ +function ModNote({ level, text, name }: { level: "info" | "warning" | "success"; text: string; name: string }) { + const { data: mods } = useMods(); + const mod = mods?.find((m) => m.name === name); + const chip = "inline-flex h-5 max-w-[14rem] shrink-0 items-center gap-1 rounded-[5px] border border-border bg-card px-1.5 text-[11px] font-medium text-foreground"; + const label = ( + <> + + {mod?.title ?? name} + + ); + return ( +
+ {mod ? ( + + {label} + + ) : ( + + {label} + + )} + {text} +
+ ); +} + const MARKDOWN_HINT = /^\s{0,3}(#{1,6}\s|[-*]\s|\|)|\*\*|`/m; const PREFORMATTED = /\n[ \t]{2,}\S/; diff --git a/apps/desktop/src/components/layout/app-shell.tsx b/apps/desktop/src/components/layout/app-shell.tsx index 43386d4c..9fcc171e 100644 --- a/apps/desktop/src/components/layout/app-shell.tsx +++ b/apps/desktop/src/components/layout/app-shell.tsx @@ -20,6 +20,7 @@ import { PanelLeftClose, PanelLeftOpen, Plug, + Puzzle, Search, Server, Settings, @@ -28,6 +29,7 @@ import { Workflow, X, } from "lucide-react"; +import { modState } from "@godmode/shared"; import { Sidebar, SidebarContent, @@ -57,7 +59,7 @@ import { ClaudeUpdateButton } from "@/components/layout/claude-update-button"; import { PageScrollContext } from "@/components/layout/page-scroll"; import { Callout } from "@/components/settings/settings-kit"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { useBootstrap } from "@/lib/hooks"; +import { useBootstrap, useMods } from "@/lib/hooks"; import { api } from "@/lib/api"; import { qk } from "@/lib/queryKeys"; import { isMac, modKey } from "@/lib/desktop"; @@ -112,6 +114,9 @@ export function AppShell({ children }: { children: ReactNode }) { const attention = boot?.counts.attention; const waiting = attention?.total ?? (boot?.counts.openQuestions ?? 0) + (boot?.counts.openMissingLogins ?? 0); const inboxCount = waiting || (boot?.counts.unreadNotifications ?? 0); + const { data: mods = [] } = useMods(); + // Mods that wait for the human: an agent's draft to review, or code the check refuses. + const modsWaiting = mods.filter((m) => ["review", "broken"].includes(modState(m))).length; // Notices when the human comes back after a while (Home then sums up what happened). useEffect(() => startPresence(), []); @@ -163,6 +168,7 @@ export function AppShell({ children }: { children: ReactNode }) { { to: "/tasks", label: "Tasks", icon: , badge: (attention?.review ?? 0) + (attention?.blocked ?? 0) || undefined }, { to: "/agents", label: "Agents", icon: , badge: runningCount || undefined }, { to: "/automations", label: "Automations", icon: , badge: attention?.automation || undefined }, + { to: "/mods", label: "Mods", icon: , badge: modsWaiting || undefined }, { to: "/activity", label: "Activity", icon: }, { to: "/inbox", label: "Inbox", icon: , badge: inboxCount || undefined }, ]; diff --git a/apps/desktop/src/components/layout/command-palette.tsx b/apps/desktop/src/components/layout/command-palette.tsx index 1e7b4d9b..5e8fc463 100644 --- a/apps/desktop/src/components/layout/command-palette.tsx +++ b/apps/desktop/src/components/layout/command-palette.tsx @@ -27,6 +27,7 @@ import { Moon, Plug, Plus, + Puzzle, ScrollText, Server, Settings, @@ -257,6 +258,9 @@ export function CommandPalette() { go("/automations")}> Automations + go("/mods")}> + Mods + go("/activity")}> Activity diff --git a/apps/desktop/src/components/mods/code-editor.tsx b/apps/desktop/src/components/mods/code-editor.tsx new file mode 100644 index 00000000..bee08d15 --- /dev/null +++ b/apps/desktop/src/components/mods/code-editor.tsx @@ -0,0 +1,220 @@ +import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { cn } from "@/lib/utils"; +import { tokenize, type CodeLanguage, type TokenKind } from "./highlight"; + +const INDENT = " "; +const LINE_HEIGHT = 20; +const PADDING_TOP = 12; +/** Past this size the text stays plain, so typing never waits for the colours. */ +const HIGHLIGHT_LIMIT = 80_000; + +/** Both layers share these, so every glyph of the textarea sits exactly on its coloured twin. */ +const TEXT = "m-0 font-mono text-[12.5px] leading-5 tracking-normal whitespace-pre [font-variant-ligatures:none] [tab-size:2]"; + +const TOKEN_CLASS: Record, string> = { + comment: "text-muted-foreground", + string: "text-brand-strong", + number: "text-warning", + literal: "text-warning", + keyword: "font-medium text-foreground", + engine: "rounded-[3px] bg-foreground/[0.08] font-medium text-foreground", + punct: "text-foreground/50", + key: "text-foreground", +}; + +function lineOf(text: string, offset: number): number { + let line = 0; + for (let i = text.indexOf("\n"); i >= 0 && i < offset; i = text.indexOf("\n", i + 1)) line++; + return line; +} + +/** Replace a range the way typing would, so the browser's undo history survives. */ +function replaceRange(el: HTMLTextAreaElement, from: number, to: number, text: string) { + el.focus(); + el.setSelectionRange(from, to); + const done = text ? document.execCommand("insertText", false, text) : from === to || document.execCommand("delete"); + if (!done) { + el.setRangeText(text, from, to, "end"); + el.dispatchEvent(new Event("input", { bubbles: true })); + } +} + +function indentLines(el: HTMLTextAreaElement, outdent: boolean) { + const { value, selectionStart: start, selectionEnd: end } = el; + if (!outdent && start === end) return replaceRange(el, start, end, INDENT); + const blockStart = value.lastIndexOf("\n", start - 1) + 1; + // A selection that ends at the start of a line doesn't take that line along. + const last = end > start && value[end - 1] === "\n" ? end - 1 : end; + const nextBreak = value.indexOf("\n", last); + const blockEnd = nextBreak < 0 ? value.length : nextBreak; + let first = 0; + let total = 0; + const lines = value + .slice(blockStart, blockEnd) + .split("\n") + .map((line, i) => { + const cut = outdent ? (/^(?: {1,2}|\t)/.exec(line)?.[0].length ?? 0) : 0; + const delta = outdent ? -cut : line ? INDENT.length : 0; + if (i === 0) first = delta; + total += delta; + return outdent ? line.slice(cut) : line ? INDENT + line : line; + }); + if (!total) return; + replaceRange(el, blockStart, blockEnd, lines.join("\n")); + const from = Math.max(blockStart, start + first); + el.setSelectionRange(from, start === end ? from : Math.max(from, end + total)); +} + +function breakLine(el: HTMLTextAreaElement) { + const { value, selectionStart: start, selectionEnd: end } = el; + const lineStart = value.lastIndexOf("\n", start - 1) + 1; + const indent = /^[ \t]*/.exec(value.slice(lineStart, start))?.[0] ?? ""; + const opens = "{[(".includes(value[start - 1] ?? " "); + const closes = opens && "}])".includes(value[end] ?? " "); + replaceRange(el, start, end, `\n${indent}${opens ? INDENT : ""}${closes ? `\n${indent}` : ""}`); + if (closes) { + const caret = start + 1 + indent.length + INDENT.length; + el.setSelectionRange(caret, caret); + } +} + +/** + * A code editor without a dependency: a transparent textarea over a highlighted copy of its text. The textarea is as + * large as the text, so the frame around both does the scrolling and the two layers can never drift apart. + */ +export function CodeEditor({ + value, + onChange, + language, + label, + onSave, + jump, + className, +}: { + value: string; + onChange: (value: string) => void; + language: CodeLanguage; + /** Accessible name, e.g. the file's path. */ + label: string; + onSave?: () => void; + /** Select this line (1-based); a new object jumps again. */ + jump?: { line: number } | null; + className?: string; +}) { + const area = useRef(null); + const frame = useRef(null); + // Tab indents; Escape lets the next Tab move the focus on, so the editor is no keyboard trap. + const leaving = useRef(false); + const hintId = useId(); + const [activeLine, setActiveLine] = useState(null); + + const numbers = useMemo(() => { + const count = lineOf(value, value.length) + 1; + return Array.from({ length: count }, (_, i) => i + 1).join("\n"); + }, [value]); + + const highlighted = useMemo( + () => ( +
+        {value.length > HIGHLIGHT_LIMIT
+          ? value
+          : tokenize(value, language).map((t, i) =>
+              t.kind === "plain" ? (
+                t.text
+              ) : (
+                
+                  {t.text}
+                
+              ),
+            )}
+        {"\n"}
+      
+ ), + [value, language], + ); + + useEffect(() => { + const el = area.current; + if (!el || !jump) return; + const lines = el.value.split("\n"); + const line = Math.min(Math.max(jump.line, 1), lines.length); + const offset = lines.slice(0, line - 1).reduce((sum, l) => sum + l.length + 1, 0); + el.focus({ preventScroll: true }); + el.setSelectionRange(offset, offset + lines[line - 1].length); + const box = frame.current; + box?.scrollTo({ top: Math.max(0, (line - 1) * LINE_HEIGHT - box.clientHeight / 3) }); + }, [jump]); + + const trackCaret = () => { + const el = area.current; + if (!el || document.activeElement !== el) return; + setActiveLine(el.selectionStart === el.selectionEnd ? lineOf(el.value, el.selectionStart) : null); + }; + + const onKeyDown = (e: KeyboardEvent) => { + const el = e.currentTarget; + const plain = !e.metaKey && !e.ctrlKey && !e.altKey; + if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "s") { + e.preventDefault(); + onSave?.(); + } else if (e.key === "Escape") { + leaving.current = true; + } else if (e.key === "Tab" && plain) { + if (leaving.current) return; + e.preventDefault(); + indentLines(el, e.shiftKey); + } else if (e.key === "Enter" && plain && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + breakLine(el); + leaving.current = false; + } else if (e.key !== "Shift") { + leaving.current = false; + } + }; + + return ( +
+
+
+          {numbers}
+        
+
+ {activeLine !== null && ( +
+ )} + {highlighted} +