diff --git a/docs/docs/config.mdx b/docs/docs/config.mdx
index 05389e99ef..d0bc4798e6 100644
--- a/docs/docs/config.mdx
+++ b/docs/docs/config.mdx
@@ -84,6 +84,7 @@ wsh editconfig
| editor:inlinediff | bool | set to true to show diffs inline instead of side-by-side, false for side-by-side (defaults to undefined which uses Monaco's responsive behavior) |
| preview:showhiddenfiles | bool | set to false to disable showing hidden files in the directory preview (defaults to true) |
| preview:defaultsort | string | sets the default sort column for directory preview. `"name"` (default) sorts alphabetically by name ascending; `"modtime"` sorts by last modified time descending (newest first) |
+| notes:path | string | path to the notes markdown file (defaults to `~/notes.md`) |
| markdown:fontsize | float64 | font size for the normal text when rendering markdown in preview. headers are scaled up from this size, (default 14px) |
| markdown:fixedfontsize | float64 | font size for the code blocks when rendering markdown in preview (default is 12px) |
| web:openlinksinternally | bool | set to false to open web links in external browser |
diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts
index 5de7e05bd3..4ca0fd7b1e 100644
--- a/frontend/app/block/blockregistry.ts
+++ b/frontend/app/block/blockregistry.ts
@@ -16,6 +16,7 @@ import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview";
import { WaveConfigViewModel } from "../view/waveconfig/waveconfig-model";
import { blockViewToIcon, blockViewToName } from "./blockutil";
import { HelpViewModel } from "@/view/helpview/helpview";
+import { NotesViewModel } from "@/app/view/notes/notes-model";
import { TermViewModel } from "@/view/term/term-model";
import { WaveAiModel } from "@/view/waveai/waveai";
import { WebViewModel } from "@/view/webview/webview";
@@ -35,6 +36,7 @@ BlockRegistry.set("tsunami", TsunamiViewModel);
BlockRegistry.set("aifilediff", AiFileDiffViewModel);
BlockRegistry.set("waveconfig", WaveConfigViewModel);
BlockRegistry.set("processviewer", ProcessViewerViewModel);
+BlockRegistry.set("notes", NotesViewModel);
function makeDefaultViewModel(viewType: string): ViewModel {
const viewModel: ViewModel = {
diff --git a/frontend/app/modals/alertmodal.tsx b/frontend/app/modals/alertmodal.tsx
new file mode 100644
index 0000000000..4f5c0b0d21
--- /dev/null
+++ b/frontend/app/modals/alertmodal.tsx
@@ -0,0 +1,110 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { modalsModel } from "@/app/store/modalmodel";
+import { makeIconClass } from "@/util/util";
+import { ReactNode, useEffect } from "react";
+import ReactDOM from "react-dom";
+
+interface AlertModalProps {
+ children: ReactNode;
+ title?: string;
+ icon?: string;
+ iconClassName?: string;
+ okLabel?: string;
+ onClose?: () => void;
+}
+
+interface AlertOptions {
+ title?: string;
+ message: ReactNode;
+ icon?: string;
+ iconClassName?: string;
+ okLabel?: string;
+}
+
+function showAlert(opts: AlertOptions) {
+ modalsModel.pushModal("AlertModal", {
+ children: opts.message,
+ title: opts.title ?? "Alert",
+ icon: opts.icon,
+ iconClassName: opts.iconClassName,
+ okLabel: opts.okLabel,
+ });
+}
+
+function showErrorAlert(message: ReactNode, title?: string) {
+ showAlert({ title: title ?? "Error", message, icon: "circle-exclamation", iconClassName: "text-error" });
+}
+
+const AlertModal = ({ children, title = "Alert", icon = "circle-info", iconClassName, okLabel = "Ok", onClose }: AlertModalProps) => {
+ function close() {
+ if (onClose) {
+ onClose();
+ } else {
+ modalsModel.popModal();
+ }
+ }
+
+ useEffect(() => {
+ function handleKeyDown(e: KeyboardEvent) {
+ if (e.key === "Escape" || e.key === "Enter") {
+ e.preventDefault();
+ e.stopPropagation();
+ close();
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown, true);
+ return () => window.removeEventListener("keydown", handleKeyDown, true);
+ }, []);
+
+ const iconClass = makeIconClass(icon, false);
+
+ return ReactDOM.createPortal(
+
+
+
+
+
+ {title}
+
+
+
{children}
+
+
+
+
+
,
+ document.getElementById("main")
+ );
+};
+
+AlertModal.displayName = "AlertModal";
+
+export { AlertModal, showAlert, showErrorAlert };
+export type { AlertOptions };
diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx
index 88d19e732c..da8f1495f1 100644
--- a/frontend/app/modals/modalregistry.tsx
+++ b/frontend/app/modals/modalregistry.tsx
@@ -1,7 +1,7 @@
// Copyright 2025, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
-import { MessageModal } from "@/app/modals/messagemodal";
+import { AlertModal } from "@/app/modals/alertmodal";
import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding";
import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade";
import { UpgradeOnboardingPatch } from "@/app/onboarding/onboarding-upgrade-patch";
@@ -16,7 +16,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = {
[UpgradeOnboardingPatch.displayName || "UpgradeOnboardingPatch"]: UpgradeOnboardingPatch,
[UserInputModal.displayName || "UserInputModal"]: UserInputModal,
[AboutModal.displayName || "AboutModal"]: AboutModal,
- [MessageModal.displayName || "MessageModal"]: MessageModal,
+ [AlertModal.displayName || "AlertModal"]: AlertModal,
[PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal,
[RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal,
[DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal,
diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts
index 8482be260d..02a674d74a 100644
--- a/frontend/app/store/wshclientapi.ts
+++ b/frontend/app/store/wshclientapi.ts
@@ -444,6 +444,12 @@ export class RpcApiType {
return client.wshRpcCall("getmeta", data, opts);
}
+ // command "getnote" [call]
+ GetNoteCommand(client: WshClient, opts?: RpcOpts): Promise {
+ if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getnote", null, opts);
+ return client.wshRpcCall("getnote", null, opts);
+ }
+
// command "getrtinfo" [call]
GetRTInfoCommand(client: WshClient, data: CommandGetRTInfoData, opts?: RpcOpts): Promise {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "getrtinfo", data, opts);
@@ -1050,6 +1056,12 @@ export class RpcApiType {
return client.wshRpcCall("writeappsecretbindings", data, opts);
}
+ // command "writenote" [call]
+ WriteNoteCommand(client: WshClient, data: CommandWriteNoteData, opts?: RpcOpts): Promise {
+ if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writenote", data, opts);
+ return client.wshRpcCall("writenote", data, opts);
+ }
+
// command "writetempfile" [call]
WriteTempFileCommand(client: WshClient, data: CommandWriteTempFileData, opts?: RpcOpts): Promise {
if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "writetempfile", data, opts);
diff --git a/frontend/app/view/notes/notes-model.ts b/frontend/app/view/notes/notes-model.ts
new file mode 100644
index 0000000000..dbc6fdf5c5
--- /dev/null
+++ b/frontend/app/view/notes/notes-model.ts
@@ -0,0 +1,344 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { BlockNodeModel } from "@/app/block/blocktypes";
+import { showErrorAlert } from "@/app/modals/alertmodal";
+import { ClientModel } from "@/app/store/client-model";
+import { globalStore } from "@/app/store/jotaiStore";
+import * as WOS from "@/app/store/wos";
+import { waveEventSubscribeSingle } from "@/app/store/wps";
+import { TabRpcClient } from "@/app/store/wshrpcutil";
+import { NotesView } from "@/app/view/notes/notes";
+import { WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv";
+import * as jotai from "jotai";
+import type * as MonacoTypes from "monaco-editor";
+import React from "react";
+import { debounce } from "throttle-debounce";
+
+type NotesEnv = WaveEnvSubset<{
+ rpc: {
+ GetNoteCommand: WaveEnv["rpc"]["GetNoteCommand"];
+ WriteNoteCommand: WaveEnv["rpc"]["WriteNoteCommand"];
+ GetRTInfoCommand: WaveEnv["rpc"]["GetRTInfoCommand"];
+ SetRTInfoCommand: WaveEnv["rpc"]["SetRTInfoCommand"];
+ SetConfigCommand: WaveEnv["rpc"]["SetConfigCommand"];
+ };
+ getSettingsKeyAtom: WaveEnv["getSettingsKeyAtom"];
+}>;
+
+type SaveStatus = "idle" | "dirty" | "saved" | "synced" | "error";
+
+const SavedDisplayMs = 3000;
+
+export class NotesViewModel implements ViewModel {
+ viewType = "notes";
+ blockId: string;
+ env: NotesEnv;
+ nodeModel: BlockNodeModel;
+
+ viewIcon = jotai.atom("note-sticky");
+ viewName = jotai.atom("Notes");
+ noPadding = jotai.atom(true);
+
+ contentAtom: jotai.PrimitiveAtom;
+ filePathAtom: jotai.PrimitiveAtom;
+ loadErrorAtom: jotai.PrimitiveAtom;
+ loadedAtom: jotai.PrimitiveAtom;
+ saveStatusAtom: jotai.PrimitiveAtom;
+ saveErrorAtom: jotai.PrimitiveAtom;
+ readOnlyAtom: jotai.PrimitiveAtom;
+
+ viewText!: jotai.Atom;
+ wordWrapAtom!: jotai.Atom;
+
+ myOref: string;
+ editorRef: React.RefObject = { current: null };
+ savedClearTimer: ReturnType = null;
+ unsubscribeNotes: () => void = null;
+ pendingContent: string = null;
+ isApplyingRemoteEdit = false;
+
+ debouncedSave = debounce(1000, (text: string) => {
+ this.saveContent(text);
+ });
+
+ debouncedSaveCursorPos = debounce(500, (pos: number) => {
+ this.env.rpc
+ .SetRTInfoCommand(TabRpcClient, {
+ oref: WOS.makeORef("client", ClientModel.getInstance().clientId),
+ data: { "notes:cursorpos": pos },
+ })
+ .catch(() => {});
+ });
+
+ constructor({ blockId, nodeModel, waveEnv }: ViewModelInitType) {
+ this.blockId = blockId;
+ this.nodeModel = nodeModel;
+ this.env = waveEnv as NotesEnv;
+ this.myOref = WOS.makeORef("block", blockId);
+ this.contentAtom = jotai.atom("");
+ this.filePathAtom = jotai.atom("");
+ this.loadErrorAtom = jotai.atom("");
+ this.loadedAtom = jotai.atom(false);
+ this.saveStatusAtom = jotai.atom("idle");
+ this.saveErrorAtom = jotai.atom("");
+ this.readOnlyAtom = jotai.atom(false);
+
+ this.viewText = jotai.atom((get) => {
+ const status = get(this.saveStatusAtom);
+ const saveError = get(this.saveErrorAtom);
+ const readOnly = get(this.readOnlyAtom);
+ const spacer: HeaderElem = { elemtype: "div", className: "flex-1", children: [] };
+ if (readOnly) {
+ return [
+ spacer,
+ { elemtype: "text", text: "Read-only", noGrow: true, className: "opacity-50" },
+ ] as HeaderElem[];
+ }
+ if (status === "dirty") {
+ return [spacer, { elemtype: "text", text: "Editing...", noGrow: true }] as HeaderElem[];
+ } else if (status === "saved") {
+ return [
+ spacer,
+ { elemtype: "text", text: "Saved ✓", noGrow: true, className: "text-accent" },
+ ] as HeaderElem[];
+ } else if (status === "synced") {
+ return [
+ spacer,
+ { elemtype: "text", text: "Synced ↓", noGrow: true, className: "text-accent" },
+ ] as HeaderElem[];
+ } else if (status === "error") {
+ return [
+ spacer,
+ {
+ elemtype: "text",
+ text: "Error Saving...",
+ noGrow: true,
+ className: "text-errormsg cursor-pointer",
+ onClick: () => showErrorAlert(saveError),
+ },
+ ] as HeaderElem[];
+ }
+ return [];
+ });
+
+ this.wordWrapAtom = jotai.atom((get) => get(waveEnv.getSettingsKeyAtom("editor:wordwrap")) ?? false);
+
+ this.loadFile();
+ this.unsubscribeNotes = waveEventSubscribeSingle({
+ eventType: "notes:updated",
+ handler: (event) => this.handleNotesUpdated(event.data),
+ });
+ }
+
+ get viewComponent(): ViewComponent {
+ return NotesView;
+ }
+
+ setEditorRef(ref: React.RefObject) {
+ this.editorRef = ref;
+ }
+
+ clearSavedTimer() {
+ if (this.savedClearTimer != null) {
+ clearTimeout(this.savedClearTimer);
+ this.savedClearTimer = null;
+ }
+ }
+
+ setSavedTimer() {
+ this.clearSavedTimer();
+ this.savedClearTimer = setTimeout(() => {
+ globalStore.set(this.saveStatusAtom, "idle");
+ this.savedClearTimer = null;
+ }, SavedDisplayMs);
+ }
+
+ onContentChange(text: string) {
+ if (this.isApplyingRemoteEdit) {
+ return;
+ }
+ this.clearSavedTimer();
+ this.pendingContent = text;
+ globalStore.set(this.saveStatusAtom, "dirty");
+ this.debouncedSave(text);
+ }
+
+ onCursorChange(pos: number) {
+ this.debouncedSaveCursorPos(pos);
+ }
+
+ onBlur() {
+ if (this.pendingContent != null) {
+ this.debouncedSave.cancel({ upcomingOnly: true });
+ this.saveContent(this.pendingContent);
+ }
+ }
+
+ async loadFile() {
+ try {
+ const noteData = await this.env.rpc.GetNoteCommand(TabRpcClient);
+ if (noteData?.error) {
+ globalStore.set(this.loadErrorAtom, noteData.error);
+ globalStore.set(this.contentAtom, "");
+ globalStore.set(this.filePathAtom, "");
+ globalStore.set(this.readOnlyAtom, false);
+ } else {
+ globalStore.set(this.contentAtom, noteData?.content ?? "");
+ globalStore.set(this.filePathAtom, noteData?.filepath ?? "");
+ globalStore.set(this.readOnlyAtom, noteData?.readonly ?? false);
+ globalStore.set(this.loadErrorAtom, "");
+ }
+ } catch (err) {
+ globalStore.set(this.loadErrorAtom, `Cannot load notes: ${err?.message ?? String(err)}`);
+ } finally {
+ globalStore.set(this.loadedAtom, true);
+ }
+ }
+
+ async restoreCursorPos() {
+ const editor = this.editorRef.current;
+ if (editor == null) {
+ return;
+ }
+ try {
+ const rtInfo = await this.env.rpc.GetRTInfoCommand(TabRpcClient, {
+ oref: WOS.makeORef("client", ClientModel.getInstance().clientId),
+ });
+ const pos = rtInfo?.["notes:cursorpos"];
+ if (!pos) {
+ return;
+ }
+ const editorModel = editor.getModel();
+ if (editorModel == null) {
+ return;
+ }
+ const position = editorModel.getPositionAt(pos);
+ editor.setPosition(position);
+ editor.revealPosition(position);
+ } catch (_e) {}
+ }
+
+ async saveContent(text: string) {
+ this.pendingContent = null;
+ console.log("[notes] saveContent start, text.len=", text.length);
+ try {
+ await this.env.rpc.WriteNoteCommand(TabRpcClient, {
+ content: text,
+ sourceoref: this.myOref,
+ });
+ console.log("[notes] saveContent success, setting saved");
+ globalStore.set(this.saveStatusAtom, "saved");
+ globalStore.set(this.saveErrorAtom, "");
+ this.setSavedTimer();
+ } catch (err) {
+ console.log("[notes] saveContent error:", err);
+ globalStore.set(this.saveStatusAtom, "error");
+ globalStore.set(this.saveErrorAtom, err?.message ?? String(err));
+ }
+ }
+
+ handleNotesUpdated(data: NotesUpdatedData) {
+ console.log(
+ "[notes] handleNotesUpdated, sourceoref=",
+ data?.sourceoref,
+ "myOref=",
+ this.myOref,
+ "currentStatus=",
+ globalStore.get(this.saveStatusAtom)
+ );
+ if (data?.sourceoref === this.myOref) {
+ console.log("[notes] handleNotesUpdated skipping (own update)");
+ return;
+ }
+ this.debouncedSave.cancel({ upcomingOnly: true });
+ this.pendingContent = null;
+ if (data?.error) {
+ const editor = this.editorRef.current;
+ if (editor != null) {
+ const editorModel = editor.getModel();
+ if (editorModel != null) {
+ this.isApplyingRemoteEdit = true;
+ try {
+ editorModel.applyEdits([{ range: editorModel.getFullModelRange(), text: "" }]);
+ } finally {
+ this.isApplyingRemoteEdit = false;
+ }
+ }
+ }
+ globalStore.set(this.contentAtom, "");
+ globalStore.set(this.filePathAtom, "");
+ globalStore.set(this.readOnlyAtom, false);
+ globalStore.set(this.loadErrorAtom, data.error);
+ globalStore.set(this.saveStatusAtom, "idle");
+ return;
+ }
+ const editor = this.editorRef.current;
+ const content = data?.content ?? "";
+ if (editor != null) {
+ const editorModel = editor.getModel();
+ if (editorModel != null) {
+ this.isApplyingRemoteEdit = true;
+ try {
+ editorModel.applyEdits([{ range: editorModel.getFullModelRange(), text: content }]);
+ } finally {
+ this.isApplyingRemoteEdit = false;
+ }
+ }
+ }
+ globalStore.set(this.contentAtom, content);
+ globalStore.set(this.loadErrorAtom, "");
+ if (data?.filepath) {
+ globalStore.set(this.filePathAtom, data.filepath);
+ }
+ if (data?.readonly != null) {
+ globalStore.set(this.readOnlyAtom, data.readonly);
+ }
+ globalStore.set(this.saveStatusAtom, "synced");
+ this.setSavedTimer();
+ }
+
+ getSettingsMenuItems(): ContextMenuItem[] {
+ const wordWrap = globalStore.get(this.wordWrapAtom);
+ const filePath = globalStore.get(this.filePathAtom);
+ const items: ContextMenuItem[] = [];
+ if (filePath) {
+ items.push({
+ label: "Copy Notes File Path",
+ click: () => {
+ navigator.clipboard.writeText(filePath);
+ },
+ });
+ items.push({ type: "separator" });
+ }
+ items.push({
+ label: "Word Wrap",
+ type: "checkbox",
+ checked: wordWrap,
+ click: () => {
+ this.env.rpc.SetConfigCommand(TabRpcClient, { "editor:wordwrap": !wordWrap });
+ },
+ });
+ return items;
+ }
+
+ giveFocus(): boolean {
+ if (this.editorRef.current) {
+ this.editorRef.current.focus();
+ return true;
+ }
+ return false;
+ }
+
+ dispose() {
+ this.debouncedSave.cancel();
+ if (this.pendingContent != null) {
+ this.saveContent(this.pendingContent);
+ }
+ this.debouncedSaveCursorPos.cancel();
+ this.clearSavedTimer();
+ if (this.unsubscribeNotes != null) {
+ this.unsubscribeNotes();
+ }
+ }
+}
diff --git a/frontend/app/view/notes/notes.tsx b/frontend/app/view/notes/notes.tsx
new file mode 100644
index 0000000000..0efc2ff077
--- /dev/null
+++ b/frontend/app/view/notes/notes.tsx
@@ -0,0 +1,85 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { MonacoCodeEditor } from "@/app/monaco/monaco-react";
+import { globalStore } from "@/app/store/jotaiStore";
+import { tryReinjectKey } from "@/app/store/keymodel";
+import { NotesViewModel } from "@/app/view/notes/notes-model";
+import { adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
+import { useAtomValue } from "jotai";
+import type * as MonacoTypes from "monaco-editor";
+import { useCallback, useRef } from "react";
+
+export function NotesView({ model }: ViewComponentProps) {
+ const content = useAtomValue(model.contentAtom);
+ const loadError = useAtomValue(model.loadErrorAtom);
+ const loaded = useAtomValue(model.loadedAtom);
+ const wordWrap = useAtomValue(model.wordWrapAtom);
+
+ const editorRef = useRef(null);
+
+ const handleMount = useCallback(
+ (editor: MonacoTypes.editor.IStandaloneCodeEditor) => {
+ editorRef.current = editor;
+ model.setEditorRef(editorRef);
+ model.restoreCursorPos();
+ editor.onDidBlurEditorWidget(() => {
+ model.onBlur();
+ });
+ editor.onDidChangeCursorPosition(() => {
+ const offset = editor.getModel()?.getOffsetAt(editor.getPosition());
+ if (offset != null) {
+ model.onCursorChange(offset);
+ }
+ });
+ const keyDownDisposer = editor.onKeyDown((e: MonacoTypes.IKeyboardEvent) => {
+ const waveEvent = adaptFromReactOrNativeKeyEvent(e.browserEvent);
+ const handled = tryReinjectKey(waveEvent);
+ if (handled) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ });
+ if (globalStore.get(model.nodeModel.isFocused)) {
+ editor.focus();
+ }
+ return () => {
+ keyDownDisposer.dispose();
+ editorRef.current = null;
+ };
+ },
+ [model]
+ );
+
+ if (!loaded) {
+ return Loading...
;
+ }
+
+ if (loadError) {
+ return (
+
+ );
+ }
+
+ return (
+ model.onContentChange(text)}
+ onMount={handleMount}
+ path={`notes-${model.blockId}.md`}
+ options={{
+ wordWrap: wordWrap ? "on" : "off",
+ minimap: { enabled: false },
+ lineNumbers: "off",
+ folding: false,
+ scrollBeyondLastLine: false,
+ quickSuggestions: false,
+ suggestOnTriggerCharacters: false,
+ }}
+ />
+ );
+}
diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts
index a256929e7d..2018dc7d9f 100644
--- a/frontend/app/view/term/term-model.ts
+++ b/frontend/app/view/term/term-model.ts
@@ -1,10 +1,10 @@
// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
+import { showAlert, showErrorAlert } from "@/app/modals/alertmodal";
import { WaveAIModel } from "@/app/aipanel/waveai-model";
import { BlockNodeModel } from "@/app/block/blocktypes";
import { appHandleKeyDown } from "@/app/store/keymodel";
-import { modalsModel } from "@/app/store/modalmodel";
import type { TabModel } from "@/app/store/tab-model";
import { waveEventSubscribeSingle } from "@/app/store/wps";
import { RpcApi } from "@/app/store/wshclientapi";
@@ -998,15 +998,11 @@ export class TermViewModel implements ViewModel {
} catch (error) {
console.error("Failed to save scrollback:", error);
const errorMessage = error?.message || "An unknown error occurred";
- modalsModel.pushModal("MessageModal", {
- children: `Failed to save session scrollback: ${errorMessage}`,
- });
+ showErrorAlert(`Failed to save session scrollback: ${errorMessage}`);
}
});
} else {
- modalsModel.pushModal("MessageModal", {
- children: "No scrollback content to save.",
- });
+ showAlert({ title: "Save Session", message: "No scrollback content to save." });
}
}
},
diff --git a/frontend/preview/mock/mockwaveenv.ts b/frontend/preview/mock/mockwaveenv.ts
index 123b9d3144..a5e1b730ac 100644
--- a/frontend/preview/mock/mockwaveenv.ts
+++ b/frontend/preview/mock/mockwaveenv.ts
@@ -23,6 +23,7 @@ export const PreviewClientId = crypto.randomUUID();
export const WebBlockId = crypto.randomUUID();
export const SysinfoBlockId = crypto.randomUUID();
export const ProcessViewerBlockId = crypto.randomUUID();
+export const NotesBlockId = crypto.randomUUID();
// What works "out of the box" in the mock environment (no MockEnv overrides needed):
//
@@ -419,6 +420,14 @@ export function makeMockWaveEnv(mockEnv?: MockEnv): MockWaveEnv {
view: "processviewer",
},
} as Block,
+ [`block:${NotesBlockId}`]: {
+ otype: "block",
+ oid: NotesBlockId,
+ version: 1,
+ meta: {
+ view: "notes",
+ },
+ } as Block,
};
const defaultAtoms: Partial = {
uiContext: atom({ windowid: PreviewWindowId, activetabid: PreviewTabId } as UIContext),
diff --git a/frontend/preview/previews/alertmodal.preview.tsx b/frontend/preview/previews/alertmodal.preview.tsx
new file mode 100644
index 0000000000..3f9687e523
--- /dev/null
+++ b/frontend/preview/previews/alertmodal.preview.tsx
@@ -0,0 +1,81 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { AlertModal } from "@/app/modals/alertmodal";
+import { useState } from "react";
+
+type PreviewEntry = {
+ label: string;
+ message: string;
+ title?: string;
+ icon?: string;
+ iconClassName?: string;
+};
+
+const previews: PreviewEntry[] = [
+ {
+ label: "Default",
+ message: "A much longer message that might wrap across multiple lines in the modal body to test layout behavior with extended content.",
+ },
+ {
+ label: "Short message",
+ message: "Short error.",
+ },
+ {
+ label: "Error",
+ title: "Save Failed",
+ message: "Error saving file: EACCES: permission denied, open '/root/notes.md'",
+ icon: "circle-exclamation",
+ iconClassName: "text-error",
+ },
+ {
+ label: "Error + custom icon",
+ title: "Connection Failed",
+ message: "Unable to connect to remote host. Check your SSH configuration and try again.",
+ icon: "plug-circle-xmark",
+ iconClassName: "text-error",
+ },
+ {
+ label: "Warning",
+ title: "Unsaved Changes",
+ message: "You have unsaved changes. They will be lost if you continue.",
+ icon: "triangle-exclamation",
+ iconClassName: "text-warning",
+ },
+];
+
+export function AlertModalPreview() {
+ const [key, setKey] = useState(0);
+ const [current, setCurrent] = useState(null);
+
+ return (
+
+
Click a button to open the AlertModal.
+
+ {previews.map((p, i) => (
+
+ ))}
+
+ {current && (
+
setCurrent(null)}
+ >
+ {current.message}
+
+ )}
+
+ );
+}
diff --git a/frontend/preview/previews/notes.preview.tsx b/frontend/preview/previews/notes.preview.tsx
new file mode 100644
index 0000000000..78d91575fd
--- /dev/null
+++ b/frontend/preview/previews/notes.preview.tsx
@@ -0,0 +1,92 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Block } from "@/app/block/block";
+import { stringToBase64 } from "@/util/util";
+import * as React from "react";
+import { makeMockNodeModel } from "../mock/mock-node-model";
+import { NotesBlockId } from "../mock/mockwaveenv";
+import { useRpcOverride } from "../mock/use-rpc-override";
+
+const PreviewNodeId = "preview-notes-node";
+
+const MockContent = `# My Notes
+
+This is a **preview** of the notes view.
+
+- Item one
+- Item two
+- Item three
+
+> Autosave is debounced — changes save after 1 second of inactivity.
+`;
+
+export default function NotesPreview() {
+ const [failRead, setFailRead] = React.useState(false);
+ const [failWrite, setFailWrite] = React.useState(false);
+ const [blockKey, setBlockKey] = React.useState(0);
+
+ const failReadRef = React.useRef(false);
+ const failWriteRef = React.useRef(false);
+
+ const nodeModel = React.useMemo(
+ () =>
+ makeMockNodeModel({
+ nodeId: PreviewNodeId,
+ blockId: NotesBlockId,
+ innerRect: { width: "700px", height: "500px" },
+ numLeafs: 1,
+ }),
+ []
+ );
+
+ useRpcOverride("FileReadCommand", async () => {
+ if (failReadRef.current) throw new Error("Permission denied: cannot read ~/notes.md");
+ return { data64: stringToBase64(MockContent) };
+ });
+
+ useRpcOverride("FileWriteCommand", async () => {
+ if (failWriteRef.current) throw new Error("Disk full: cannot write ~/notes.md");
+ return null;
+ });
+
+ function toggleFailRead(val: boolean) {
+ failReadRef.current = val;
+ setFailRead(val);
+ setBlockKey((k) => k + 1);
+ }
+
+ function toggleFailWrite(val: boolean) {
+ failWriteRef.current = val;
+ setFailWrite(val);
+ }
+
+ return (
+
+
notes block (mock RPC — FileReadCommand / FileWriteCommand)
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css
index 3a0523c8ce..789ce43cbd 100644
--- a/frontend/tailwindsetup.css
+++ b/frontend/tailwindsetup.css
@@ -24,6 +24,7 @@
--color-accent-800: rgb(22, 81, 35);
--color-accent-900: rgb(15, 61, 29);
--color-error: rgb(229, 77, 46);
+ --color-errormsg: rgb(255, 80, 60);
--color-warning: rgb(224, 185, 86);
--color-success: rgb(78, 154, 6);
--color-panel: rgba(31, 33, 31, 0.5);
diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts
index c5b870d7ed..2df00dd053 100644
--- a/frontend/types/gotypes.d.ts
+++ b/frontend/types/gotypes.d.ts
@@ -793,6 +793,12 @@ declare global {
bindings: {[key: string]: string};
};
+ // wshrpc.CommandWriteNoteData
+ type CommandWriteNoteData = {
+ content: string;
+ sourceoref: string;
+ };
+
// wshrpc.CommandWriteTempFileData
type CommandWriteTempFileData = {
filename: string;
@@ -1225,6 +1231,23 @@ declare global {
color: string;
};
+ // wshrpc.NoteData
+ type NoteData = {
+ content: string;
+ readonly?: boolean;
+ filepath?: string;
+ error?: string;
+ };
+
+ // wshrpc.NotesUpdatedData
+ type NotesUpdatedData = {
+ content: string;
+ sourceoref: string;
+ readonly?: boolean;
+ filepath?: string;
+ error?: string;
+ };
+
// waveobj.ORef
type ORef = string;
@@ -1249,6 +1272,7 @@ declare global {
"waveai:chatid"?: string;
"waveai:mode"?: string;
"waveai:maxoutputtokens"?: number;
+ "notes:cursorpos"?: number;
};
// wshrpc.PathCommandData
@@ -1484,6 +1508,7 @@ declare global {
"tsunami:sdkreplacepath"?: string;
"tsunami:sdkversion"?: string;
"tsunami:gopath"?: string;
+ "notes:path"?: string;
};
// waveobj.StickerClickOptsType
@@ -1589,6 +1614,7 @@ declare global {
"debug:panictype"?: string;
"block:view"?: string;
"block:controller"?: string;
+ "block:subblock"?: boolean;
"ai:backendtype"?: string;
"ai:local"?: boolean;
"wsh:cmd"?: string;
diff --git a/frontend/types/waveevent.d.ts b/frontend/types/waveevent.d.ts
index c3e5bd7822..4e6a4ae4bc 100644
--- a/frontend/types/waveevent.d.ts
+++ b/frontend/types/waveevent.d.ts
@@ -26,6 +26,7 @@ declare global {
| "waveai:modeconfig"
| "block:jobstatus"
| "badge"
+ | "notes:updated"
;
type WaveEvent = {
@@ -53,7 +54,8 @@ declare global {
{ event: "tsunami:updatemeta"; data?: AppMeta; } |
{ event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } |
{ event: "block:jobstatus"; data?: BlockJobStatusData; } |
- { event: "badge"; data?: BadgeEvent; }
+ { event: "badge"; data?: BadgeEvent; } |
+ { event: "notes:updated"; data?: NotesUpdatedData; }
);
}
diff --git a/pkg/notesmanager/notesmanager.go b/pkg/notesmanager/notesmanager.go
new file mode 100644
index 0000000000..7943efc7e6
--- /dev/null
+++ b/pkg/notesmanager/notesmanager.go
@@ -0,0 +1,330 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+package notesmanager
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/fsnotify/fsnotify"
+ "github.com/wavetermdev/waveterm/pkg/wavebase"
+ "github.com/wavetermdev/waveterm/pkg/wconfig"
+ "github.com/wavetermdev/waveterm/pkg/wps"
+ "github.com/wavetermdev/waveterm/pkg/wshrpc"
+)
+
+type NotesManager struct {
+ lock sync.Mutex
+ currentContent string
+ lastWrittenModTime time.Time
+ watcher *fsnotify.Watcher
+ configuredPath string
+ notesPath string
+ tmpPath string
+ initialized bool
+ initErr error // fatal: reads and writes both fail
+ readOnlyErr error // non-fatal: reads work, writes fail
+ handlerRegistered bool
+}
+
+var instance *NotesManager
+var instanceLock sync.Mutex
+
+func GetNotesManager() *NotesManager {
+ instanceLock.Lock()
+ defer instanceLock.Unlock()
+ if instance == nil {
+ instance = &NotesManager{}
+ }
+ return instance
+}
+
+func notesFilePaths() (string, string, string, error) {
+ configuredPath := wconfig.GetWatcher().GetFullConfig().Settings.NotesPath
+ if configuredPath == "" {
+ configuredPath = "~/notes.md"
+ }
+ notesPath, err := wavebase.ExpandHomeDir(configuredPath)
+ if err != nil {
+ return configuredPath, "", "", fmt.Errorf("expanding notes path: %w", err)
+ }
+ if !filepath.IsAbs(notesPath) {
+ return configuredPath, "", "", fmt.Errorf("notes path %q must be an absolute path (fix via config key \"notes:path\")", configuredPath)
+ }
+ tmpPath := notesPath + ".tmp"
+ return configuredPath, notesPath, tmpPath, nil
+}
+
+func normalizeContent(s string) string {
+ return strings.ReplaceAll(s, "\r\n", "\n")
+}
+
+// ensureInit lazily initializes the file watcher and reads the current content.
+// Must be called with nm.lock held.
+func (nm *NotesManager) ensureInit() error {
+ if nm.initialized {
+ return nm.initErr
+ }
+ nm.initialized = true
+ nm.initErr = nm.doInit()
+ if nm.initErr != nil {
+ log.Printf("notesmanager: init error (configured path: %q): %v\n", nm.configuredPath, nm.initErr)
+ } else {
+ log.Printf("notesmanager: init success, path: %s\n", nm.notesPath)
+ }
+ return nm.initErr
+}
+
+func (nm *NotesManager) doInit() error {
+ if !nm.handlerRegistered {
+ wconfig.GetWatcher().RegisterUpdateHandler(nm.onConfigUpdate)
+ nm.handlerRegistered = true
+ }
+
+ configuredPath, notesPath, tmpPath, err := notesFilePaths()
+ if err != nil {
+ nm.configuredPath = configuredPath
+ return err
+ }
+ nm.configuredPath = configuredPath
+
+ // reject if path is a directory or otherwise not a regular file
+ if info, statErr := os.Stat(notesPath); statErr == nil {
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("notes path %q is not a regular file", notesPath)
+ }
+ } else if !os.IsNotExist(statErr) {
+ return fmt.Errorf("cannot stat notes file: %w", statErr)
+ }
+
+ // validate parent directory is accessible
+ parentDir := filepath.Dir(notesPath)
+ if info, statErr := os.Stat(parentDir); statErr != nil {
+ return fmt.Errorf("notes directory %q is not accessible: %w", parentDir, statErr)
+ } else if !info.IsDir() {
+ return fmt.Errorf("notes parent path %q is not a directory", parentDir)
+ }
+
+ nm.notesPath = notesPath
+ nm.tmpPath = tmpPath
+
+ // probe directory write+delete permissions using the tmp path
+ probeWriteErr := os.WriteFile(tmpPath, []byte{}, 0644)
+ if probeWriteErr == nil {
+ probeWriteErr = os.Remove(tmpPath)
+ }
+ if probeWriteErr != nil {
+ nm.readOnlyErr = fmt.Errorf("notes directory %q is not writable: %w", parentDir, probeWriteErr)
+ }
+
+ data, err := os.ReadFile(notesPath)
+ if os.IsNotExist(err) {
+ if nm.readOnlyErr == nil {
+ // create blank file now that we've confirmed write access
+ if writeErr := os.WriteFile(notesPath, []byte{}, 0644); writeErr != nil {
+ return fmt.Errorf("cannot create notes file %q: %w", notesPath, writeErr)
+ }
+ }
+ } else if err != nil {
+ return fmt.Errorf("cannot read notes file: %w", err)
+ } else {
+ nm.currentContent = normalizeContent(string(data))
+ }
+
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return fmt.Errorf("creating file watcher: %w", err)
+ }
+ // watch the parent directory so renames into place are caught
+ if watchErr := watcher.Add(parentDir); watchErr != nil {
+ watcher.Close()
+ return fmt.Errorf("adding watch path: %w", watchErr)
+ }
+ nm.watcher = watcher
+
+ go nm.watchLoop()
+ return nil
+}
+
+func (nm *NotesManager) watchLoop() {
+ for {
+ select {
+ case event, ok := <-nm.watcher.Events:
+ if !ok {
+ return
+ }
+ if filepath.Clean(event.Name) != filepath.Clean(nm.notesPath) {
+ continue
+ }
+ if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
+ continue
+ }
+ nm.handleExternalChange()
+ case _, ok := <-nm.watcher.Errors:
+ if !ok {
+ return
+ }
+ }
+ }
+}
+
+func (nm *NotesManager) handleExternalChange() {
+ data, err := os.ReadFile(nm.notesPath)
+ if err != nil && !os.IsNotExist(err) {
+ return
+ }
+ var content string
+ if err == nil {
+ content = normalizeContent(string(data))
+ }
+
+ var modTime time.Time
+ if info, statErr := os.Stat(nm.notesPath); statErr == nil {
+ modTime = info.ModTime()
+ }
+
+ nm.lock.Lock()
+ if !modTime.IsZero() && modTime.Equal(nm.lastWrittenModTime) {
+ nm.lock.Unlock()
+ return
+ }
+ if content == nm.currentContent {
+ nm.lock.Unlock()
+ return
+ }
+ nm.currentContent = content
+ nm.lock.Unlock()
+
+ wps.Broker.Publish(wps.WaveEvent{
+ Event: wps.Event_NotesUpdated,
+ Data: wshrpc.NotesUpdatedData{
+ Content: content,
+ SourceOref: "external",
+ ReadOnly: nm.readOnlyErr != nil,
+ FilePath: nm.notesPath,
+ },
+ })
+}
+
+func (nm *NotesManager) resetInit() {
+ if nm.watcher != nil {
+ nm.watcher.Close()
+ nm.watcher = nil
+ }
+ nm.initialized = false
+ nm.initErr = nil
+ nm.readOnlyErr = nil
+ nm.configuredPath = ""
+ nm.notesPath = ""
+ nm.tmpPath = ""
+ nm.lastWrittenModTime = time.Time{}
+ nm.currentContent = ""
+}
+
+func (nm *NotesManager) onConfigUpdate(_ wconfig.FullConfigType) {
+ _, newNotesPath, _, pathErr := notesFilePaths()
+
+ nm.lock.Lock()
+ if pathErr == nil && newNotesPath == nm.notesPath && nm.initErr == nil {
+ nm.lock.Unlock()
+ return
+ }
+ nm.resetInit()
+ nm.lock.Unlock()
+
+ if pathErr != nil {
+ wps.Broker.Publish(wps.WaveEvent{
+ Event: wps.Event_NotesUpdated,
+ Data: wshrpc.NotesUpdatedData{
+ SourceOref: "pathchange",
+ Error: pathErr.Error(),
+ },
+ })
+ return
+ }
+
+ nm.lock.Lock()
+ initErr := nm.ensureInit()
+ content := nm.currentContent
+ notesPath := nm.notesPath
+ readOnly := nm.readOnlyErr != nil
+ nm.lock.Unlock()
+
+ if initErr != nil {
+ wps.Broker.Publish(wps.WaveEvent{
+ Event: wps.Event_NotesUpdated,
+ Data: wshrpc.NotesUpdatedData{
+ SourceOref: "pathchange",
+ Error: initErr.Error(),
+ },
+ })
+ return
+ }
+ wps.Broker.Publish(wps.WaveEvent{
+ Event: wps.Event_NotesUpdated,
+ Data: wshrpc.NotesUpdatedData{
+ Content: content,
+ SourceOref: "pathchange",
+ ReadOnly: readOnly,
+ FilePath: notesPath,
+ },
+ })
+}
+
+func (nm *NotesManager) GetNote(ctx context.Context) (wshrpc.NoteData, error) {
+ nm.lock.Lock()
+ defer nm.lock.Unlock()
+ if err := nm.ensureInit(); err != nil {
+ return wshrpc.NoteData{}, err
+ }
+ return wshrpc.NoteData{
+ Content: nm.currentContent,
+ ReadOnly: nm.readOnlyErr != nil,
+ FilePath: nm.notesPath,
+ }, nil
+}
+
+
+func (nm *NotesManager) WriteNote(ctx context.Context, data wshrpc.CommandWriteNoteData) error {
+ content := normalizeContent(data.Content)
+
+ nm.lock.Lock()
+ defer nm.lock.Unlock()
+ if err := nm.ensureInit(); err != nil {
+ return err
+ }
+ if nm.readOnlyErr != nil {
+ return nm.readOnlyErr
+ }
+
+ if err := os.WriteFile(nm.tmpPath, []byte(content), 0644); err != nil {
+ return fmt.Errorf("writing notes temp file: %w", err)
+ }
+ if err := os.Rename(nm.tmpPath, nm.notesPath); err != nil {
+ return fmt.Errorf("renaming notes temp file: %w", err)
+ }
+
+ info, err := os.Stat(nm.notesPath)
+ if err != nil {
+ return fmt.Errorf("stat after write: %w", err)
+ }
+ nm.lastWrittenModTime = info.ModTime()
+ nm.currentContent = content
+
+ wps.Broker.Publish(wps.WaveEvent{
+ Event: wps.Event_NotesUpdated,
+ Data: wshrpc.NotesUpdatedData{
+ Content: content,
+ SourceOref: data.SourceOref,
+ FilePath: nm.notesPath,
+ },
+ })
+ return nil
+}
diff --git a/pkg/tsgen/tsgenevent.go b/pkg/tsgen/tsgenevent.go
index 6e1c08e981..7f4753e920 100644
--- a/pkg/tsgen/tsgenevent.go
+++ b/pkg/tsgen/tsgenevent.go
@@ -41,6 +41,7 @@ var WaveEventDataTypes = map[string]reflect.Type{
wps.Event_AIModeConfig: reflect.TypeOf(wconfig.AIModeConfigUpdate{}),
wps.Event_BlockJobStatus: reflect.TypeOf(wshrpc.BlockJobStatusData{}),
wps.Event_Badge: reflect.TypeOf(baseds.BadgeEvent{}),
+ wps.Event_NotesUpdated: reflect.TypeOf(wshrpc.NotesUpdatedData{}),
}
func getWaveEventDataTSType(eventName string, tsTypesMap map[reflect.Type]string) string {
diff --git a/pkg/waveobj/objrtinfo.go b/pkg/waveobj/objrtinfo.go
index a7b35bbd86..0f991a9f85 100644
--- a/pkg/waveobj/objrtinfo.go
+++ b/pkg/waveobj/objrtinfo.go
@@ -26,4 +26,6 @@ type ObjRTInfo struct {
WaveAIChatId string `json:"waveai:chatid,omitempty"`
WaveAIMode string `json:"waveai:mode,omitempty"`
WaveAIMaxOutputTokens int `json:"waveai:maxoutputtokens,omitempty"`
+
+ NotesCursorPos int `json:"notes:cursorpos,omitempty"`
}
diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json
index d8847cabf2..abb7f15a58 100644
--- a/pkg/wconfig/defaultconfig/settings.json
+++ b/pkg/wconfig/defaultconfig/settings.json
@@ -40,5 +40,6 @@
"term:durable": false,
"waveai:showcloudmodes": true,
"waveai:defaultmode": "waveai@balanced",
- "preview:defaultsort": "name"
+ "preview:defaultsort": "name",
+ "notes:path": "~/notes.md"
}
diff --git a/pkg/wconfig/metaconsts.go b/pkg/wconfig/metaconsts.go
index 7d5bba5d9d..97e0566785 100644
--- a/pkg/wconfig/metaconsts.go
+++ b/pkg/wconfig/metaconsts.go
@@ -131,5 +131,7 @@ const (
ConfigKey_TsunamiSdkReplacePath = "tsunami:sdkreplacepath"
ConfigKey_TsunamiSdkVersion = "tsunami:sdkversion"
ConfigKey_TsunamiGoPath = "tsunami:gopath"
+
+ ConfigKey_NotesPath = "notes:path"
)
diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go
index 67118b1670..5f97c3a5f3 100644
--- a/pkg/wconfig/settingsconfig.go
+++ b/pkg/wconfig/settingsconfig.go
@@ -182,6 +182,8 @@ type SettingsType struct {
TsunamiSdkReplacePath string `json:"tsunami:sdkreplacepath,omitempty"`
TsunamiSdkVersion string `json:"tsunami:sdkversion,omitempty"`
TsunamiGoPath string `json:"tsunami:gopath,omitempty"`
+
+ NotesPath string `json:"notes:path,omitempty"`
}
func (s *SettingsType) GetAiSettings() *AiSettingsType {
diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go
index 0077ec9d5e..2951f74c73 100644
--- a/pkg/wps/wpstypes.go
+++ b/pkg/wps/wpstypes.go
@@ -34,6 +34,7 @@ const (
Event_AIModeConfig = "waveai:modeconfig" // type: wconfig.AIModeConfigUpdate
Event_BlockJobStatus = "block:jobstatus" // type: wshrpc.BlockJobStatusData
Event_Badge = "badge" // type: baseds.BadgeEvent
+ Event_NotesUpdated = "notes:updated" // type: wshrpc.NotesUpdatedData
)
var AllEvents []string = []string{
@@ -56,6 +57,7 @@ var AllEvents []string = []string{
Event_AIModeConfig,
Event_BlockJobStatus,
Event_Badge,
+ Event_NotesUpdated,
}
type WaveEvent struct {
diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go
index d5333aec2b..1493e205a0 100644
--- a/pkg/wshrpc/wshclient/wshclient.go
+++ b/pkg/wshrpc/wshclient/wshclient.go
@@ -442,6 +442,12 @@ func GetMetaCommand(w *wshutil.WshRpc, data wshrpc.CommandGetMetaData, opts *wsh
return resp, err
}
+// command "getnote", wshserver.GetNoteCommand
+func GetNoteCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) (wshrpc.NoteData, error) {
+ resp, err := sendRpcRequestCallHelper[wshrpc.NoteData](w, "getnote", nil, opts)
+ return resp, err
+}
+
// command "getrtinfo", wshserver.GetRTInfoCommand
func GetRTInfoCommand(w *wshutil.WshRpc, data wshrpc.CommandGetRTInfoData, opts *wshrpc.RpcOpts) (*waveobj.ObjRTInfo, error) {
resp, err := sendRpcRequestCallHelper[*waveobj.ObjRTInfo](w, "getrtinfo", data, opts)
@@ -1042,6 +1048,12 @@ func WriteAppSecretBindingsCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteAp
return err
}
+// command "writenote", wshserver.WriteNoteCommand
+func WriteNoteCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteNoteData, opts *wshrpc.RpcOpts) error {
+ _, err := sendRpcRequestCallHelper[any](w, "writenote", data, opts)
+ return err
+}
+
// command "writetempfile", wshserver.WriteTempFileCommand
func WriteTempFileCommand(w *wshutil.WshRpc, data wshrpc.CommandWriteTempFileData, opts *wshrpc.RpcOpts) (string, error) {
resp, err := sendRpcRequestCallHelper[string](w, "writetempfile", data, opts)
diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go
index 51e2338ba8..e899a369f1 100644
--- a/pkg/wshrpc/wshrpctypes.go
+++ b/pkg/wshrpc/wshrpctypes.go
@@ -97,6 +97,8 @@ type WshRpcInterface interface {
UpdateTabNameCommand(ctx context.Context, tabId string, newName string) error
UpdateWorkspaceTabIdsCommand(ctx context.Context, workspaceId string, tabIds []string) error
GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEvent, error)
+ WriteNoteCommand(ctx context.Context, data CommandWriteNoteData) error
+ GetNoteCommand(ctx context.Context) (NoteData, error)
// connection functions
ConnStatusCommand(ctx context.Context) ([]ConnStatus, error)
@@ -341,7 +343,6 @@ type CommandEventReadHistoryData struct {
MaxItems int `json:"maxitems"`
}
-
type CpuDataRequest struct {
Id string `json:"id"`
Count int `json:"count"`
@@ -921,6 +922,26 @@ type CommandRemoteProcessListData struct {
KeepAlive bool `json:"keepalive,omitempty"`
}
+type CommandWriteNoteData struct {
+ Content string `json:"content"`
+ SourceOref string `json:"sourceoref"`
+}
+
+type NoteData struct {
+ Content string `json:"content"`
+ ReadOnly bool `json:"readonly,omitempty"`
+ FilePath string `json:"filepath,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type NotesUpdatedData struct {
+ Content string `json:"content"`
+ SourceOref string `json:"sourceoref"`
+ ReadOnly bool `json:"readonly,omitempty"`
+ FilePath string `json:"filepath,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
type CommandRemoteProcessSignalData struct {
Pid int32 `json:"pid"`
Signal string `json:"signal"`
diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go
index 38006fd9a8..46af79e90f 100644
--- a/pkg/wshrpc/wshserver/wshserver.go
+++ b/pkg/wshrpc/wshserver/wshserver.go
@@ -33,6 +33,7 @@ import (
"github.com/wavetermdev/waveterm/pkg/genconn"
"github.com/wavetermdev/waveterm/pkg/jobcontroller"
"github.com/wavetermdev/waveterm/pkg/panichandler"
+ "github.com/wavetermdev/waveterm/pkg/notesmanager"
"github.com/wavetermdev/waveterm/pkg/remote"
"github.com/wavetermdev/waveterm/pkg/remote/conncontroller"
"github.com/wavetermdev/waveterm/pkg/remote/fileshare/wshfs"
@@ -1472,6 +1473,14 @@ func (ws *WshServer) GetAllBadgesCommand(ctx context.Context) ([]baseds.BadgeEve
return wcore.GetAllBadges(), nil
}
+func (ws *WshServer) WriteNoteCommand(ctx context.Context, data wshrpc.CommandWriteNoteData) error {
+ return notesmanager.GetNotesManager().WriteNote(ctx, data)
+}
+
+func (ws *WshServer) GetNoteCommand(ctx context.Context) (wshrpc.NoteData, error) {
+ return notesmanager.GetNotesManager().GetNote(ctx)
+}
+
func (ws *WshServer) GetSecretsCommand(ctx context.Context, names []string) (map[string]string, error) {
result := make(map[string]string)
for _, name := range names {
diff --git a/schema/settings.json b/schema/settings.json
index f341a0f365..6b2d3b4c84 100644
--- a/schema/settings.json
+++ b/schema/settings.json
@@ -351,6 +351,9 @@
},
"tsunami:gopath": {
"type": "string"
+ },
+ "notes:path": {
+ "type": "string"
}
},
"additionalProperties": false,