Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ Type `/` at the start of a line (or after whitespace) in the editor to open an i
| `/toc` | Generates a table of contents via AI |
| `/fix-code` | Fixes code block fences via AI |
| `/insert-table` | Inserts a Markdown table (no AI call) |
| `/insert-toc` | Inserts a table of contents generated locally from the document headings (no AI call) |

Use the arrow keys to navigate, Enter to run a command, or Escape to dismiss the menu.

Expand Down
23 changes: 23 additions & 0 deletions backend/routers/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,3 +304,26 @@ def get_outline(payload: OutlinePayload):
"""
outline = _extract_outline(payload.content)
return {"outline": outline, "heading_count": len(outline)}


@router.post("/toc")
def generate_toc(payload: OutlinePayload):
"""Generate a Markdown table of contents from the document's headings.

This is the offline, deterministic counterpart to the AI-powered ``/toc``
automation. It reuses the same heading and anchor logic
(``backend.ai_logic._generate_markdown_toc``) so the generated links resolve
against the rendered preview exactly like the AI version, but it never
contacts an AI provider, requires an API key, or needs a network connection.

The import is deferred to keep this lightweight router cheap to import during
packaged app startup, mirroring the conversion endpoints above.

:param payload: The Markdown document text to build the table of contents from.

:return: A mapping ``{"toc": <markdown>}``. ``toc`` is an empty string when
the document contains no headings.
"""
from backend.ai_logic import _generate_markdown_toc

return {"toc": _generate_markdown_toc(payload.content)}
24 changes: 22 additions & 2 deletions frontend/src/components/AIPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState, useRef, useEffect, useLayoutEffect, useCallback, type ReactNode } from "react";
import { useAIChat, type TranslationPair, type TranslationProgress } from "@/hooks/useAIChat";
import { AI, getDefaultAISettings, type AISettings, Knowledge, type KnowledgeStatus } from "@/lib/api";
import { AI, getDefaultAISettings, type AISettings, Knowledge, type KnowledgeStatus, Markdown } from "@/lib/api";
import { tokenizeInlineMarkdown } from "@/lib/inline-markdown.mjs";
import ProjectWorkReview from "@/components/ProjectWorkReview";
import type { useProjectWork } from "@/hooks/useProjectWork";
Expand Down Expand Up @@ -172,6 +172,8 @@ export default function AIPanel({
executeWork,
isWorking,
}: Props) {
const currentDocumentRef = useRef({ documentId, documentText });
currentDocumentRef.current = { documentId, documentText };
const { messages, loading, error, lastOperation, sendMessage, translate, translateSentences, cancelTranslation, clearHistory } = useAIChat(documentId);
const chatLoading = loading && lastOperation === "chat";
const chatError = lastOperation === "chat" ? error : null;
Expand Down Expand Up @@ -598,6 +600,24 @@ export default function AIPanel({
onApplyAction?.("replace_selection", INSERT_TABLE_MARKDOWN);
return;
}
if (slashCommand === "/insert-toc") {
// Offline table of contents generated from the document headings.
const requestDocument = { documentId, documentText };
try {
const { toc } = await Markdown.toc(documentText);
if (
toc.trim() &&
currentDocumentRef.current.documentId === requestDocument.documentId &&
currentDocumentRef.current.documentText === requestDocument.documentText
) {
onApplyAction?.("replace_selection", toc);
}
} catch {
// Network/backend errors are surfaced by the editor slash command path;
// here we simply skip inserting rather than interrupting the chat.
}
return;
}
const sendOptions = {
useKnowledgeBase: knowledgeEnabled && Boolean(knowledgeStatus?.exists),
};
Expand Down Expand Up @@ -801,7 +821,7 @@ export default function AIPanel({
<p className="text-gray-400 dark:text-gray-500 text-xs">
{knowledgeStatus?.exists && knowledgeEnabled
? "Ask anything about your notes, or try /summarize, /format, /toc, /fix-code."
: "Try /summarize, /translate, /format, /toc, /fix-code, or /insert-table."}
: "Try /summarize, /translate, /format, /toc, /fix-code, /insert-table, or /insert-toc."}
</p>
)}
{messages.map((msg) => (
Expand Down
1 change: 1 addition & 0 deletions frontend/src/hooks/editor/useEditorCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function useEditorCommands({
monacoReady,
slash,
insertTable: formatting.insertTable,
insertTableOfContents: formatting.insertTableOfContents,
executeAIPrompt,
documentText: editor.activeTab.content,
syncSelectedText,
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/hooks/editor/useEditorFormatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export function useEditorFormatting(
}));
}, [replaceSelection]);

const insertTableOfContents = useCallback(
(toc: string) => {
replaceSelection("insert-toc", () => ({ text: toc }));
},
[replaceSelection]
);

const insertCitation = useCallback(
(citationKey: string) => {
replaceSelection("insert-citation", () => ({
Expand All @@ -87,6 +94,7 @@ export function useEditorFormatting(
wrapSelection,
applyHeading,
insertTable,
insertTableOfContents,
insertCitation,
};
}
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/hooks/editor/useSlashCommandWiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@ import {
type SlashCommand,
type useSlashCommands,
} from "@/hooks/useSlashCommands";
import { Markdown } from "@/lib/api";
import {
captureTocInsertionTarget,
isTocInsertionTargetCurrent,
} from "@/lib/toc-insertion-guard.mjs";

export type UseSlashCommandWiringOptions = {
monacoRef: MutableRefObject<MonacoEditor.IStandaloneCodeEditor | null>;
monacoReady: boolean;
slash: ReturnType<typeof useSlashCommands>;
insertTable: () => void;
insertTableOfContents: (toc: string) => void;
executeAIPrompt: (prompt: string, documentText: string) => Promise<string | null | undefined>;
documentText: string;
syncSelectedText: () => void;
Expand All @@ -27,6 +33,7 @@ export function useSlashCommandWiring({
monacoReady,
slash,
insertTable,
insertTableOfContents,
executeAIPrompt,
documentText,
syncSelectedText,
Expand Down Expand Up @@ -63,6 +70,25 @@ export function useSlashCommandWiring({
insertTable();
return;
}
if (command.kind === "insert-toc") {
// Build the table of contents offline from the current buffer and
// insert it at the cursor. No AI provider or network access is used.
const insertionTarget = captureTocInsertionTarget(mono);
try {
const { toc } = await Markdown.toc(text);
if (!toc.trim()) {
alert("No headings found to build a table of contents.");
return;
}
if (!isTocInsertionTargetCurrent(insertionTarget, monacoRef.current)) {
return;
}
insertTableOfContents(toc);
} catch (err) {
alert(`Failed to generate table of contents: ${err instanceof Error ? err.message : String(err)}`);
}
return;
}
if (command.kind === "open-translate-tab") {
setAiPanelInitialTab("translate");
setShowAIPanel(true);
Expand All @@ -83,6 +109,7 @@ export function useSlashCommandWiring({
documentText,
executeAIPrompt,
insertTable,
insertTableOfContents,
monacoRef,
setAiPanelInitialTab,
setShowAIPanel,
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/hooks/useSlashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ export type SlashCommandId =
| "format"
| "toc"
| "fix-code"
| "insert-table";
| "insert-table"
| "insert-toc";

export type SlashCommand = {
id: SlashCommandId;
label: string;
description: string;
kind: "ai" | "open-translate-tab" | "insert-table";
kind: "ai" | "open-translate-tab" | "insert-table" | "insert-toc";
prompt?: string;
};

Expand All @@ -27,6 +28,7 @@ const STATIC_COMMANDS: Omit<SlashCommand, "prompt">[] = [
{ id: "toc", label: "/toc", description: "Generate a table of contents with AI", kind: "ai" },
{ id: "fix-code", label: "/fix-code", description: "Fix code block fences with AI", kind: "ai" },
{ id: "insert-table", label: "/insert-table", description: "Insert a Markdown table", kind: "insert-table" },
{ id: "insert-toc", label: "/insert-toc", description: "Insert a table of contents (offline, no AI)", kind: "insert-toc" },
];

const TEMPLATE_ID_BY_COMMAND: Record<string, string> = {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,14 @@ export const Markdown = {
body: JSON.stringify({ content }),
}),

// Offline, deterministic table of contents built from the document headings.
// Returns an empty `toc` string when the document has no headings.
toc: (content: string) =>
apiFetch<{ toc: string }>("/api/markdown/toc", {
method: "POST",
body: JSON.stringify({ content }),
}),

openPreviewInBrowser: (payload: RenderPayload) =>
apiFetch<BrowserPreviewResult>("/api/markdown/open-preview", {
method: "POST",
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/lib/toc-insertion-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Capture the editor state that owns an asynchronous table-of-contents request.
* A completed request may only write back to this exact, unchanged insertion
* target; otherwise a tab switch or user edit could receive stale content.
*/
export function captureTocInsertionTarget(editor) {
const model = editor?.getModel();
const selection = editor?.getSelection();
if (!editor || !model || !selection) return null;

return {
editor,
model,
versionId: model.getVersionId(),
selection: {
startLineNumber: selection.startLineNumber,
startColumn: selection.startColumn,
endLineNumber: selection.endLineNumber,
endColumn: selection.endColumn,
},
};
}

/** Return whether an asynchronous TOC result still has its original target. */
export function isTocInsertionTargetCurrent(target, editor) {
if (!target || editor !== target.editor || editor.getModel() !== target.model) return false;
if (target.model.getVersionId() !== target.versionId) return false;

const selection = editor.getSelection();
return Boolean(
selection &&
selection.startLineNumber === target.selection.startLineNumber &&
selection.startColumn === target.selection.startColumn &&
selection.endLineNumber === target.selection.endLineNumber &&
selection.endColumn === target.selection.endColumn
);
}
85 changes: 85 additions & 0 deletions frontend/tests/toc-insertion-guard.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import {
captureTocInsertionTarget,
isTocInsertionTargetCurrent,
} from "../src/lib/toc-insertion-guard.mjs";

function createEditor(name) {
const model = {
name,
version: 1,
getVersionId() {
return this.version;
},
};
return {
model,
selection: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 },
getModel() {
return this.model;
},
getSelection() {
return this.selection;
},
};
}

function deferred() {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
return { promise, resolve };
}

test("a deferred TOC result is not inserted after switching tabs", async () => {
const editorA = createEditor("A");
const editorB = createEditor("B");
let activeEditor = editorA;
const target = captureTocInsertionTarget(editorA);
const request = deferred();
const inserted = [];

const completion = request.promise.then(() => {
if (isTocInsertionTargetCurrent(target, activeEditor)) inserted.push("TOC");
});
activeEditor = editorB;
request.resolve();
await completion;

assert.deepEqual(inserted, []);
});

test("a deferred TOC result is not inserted after an edit or cursor move", async () => {
const editor = createEditor("A");
const target = captureTocInsertionTarget(editor);
const request = deferred();
const inserted = [];

const completion = request.promise.then(() => {
if (isTocInsertionTargetCurrent(target, editor)) inserted.push("TOC");
});
editor.model.version += 1;
editor.selection = { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 1 };
request.resolve();
await completion;

assert.deepEqual(inserted, []);
});

test("a deferred TOC result inserts at an unchanged target", async () => {
const editor = createEditor("A");
const target = captureTocInsertionTarget(editor);
const request = deferred();
const inserted = [];

const completion = request.promise.then(() => {
if (isTocInsertionTargetCurrent(target, editor)) inserted.push("TOC");
});
request.resolve();
await completion;

assert.deepEqual(inserted, ["TOC"]);
});
4 changes: 3 additions & 1 deletion tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,9 @@ def test_punctuation_not_counted(self):
self.assertEqual(count, 2)

def test_word_count_includes_heading_count(self):
payload = RenderPayload(content="# Title\n\nSome text.\n\n## Section\n\nMore text.")
payload = RenderPayload(
content="# Title\n\nSome text.\n\n## Section\n\nMore text."
)
result = word_count(payload)
self.assertEqual(result["heading_count"], 2)
self.assertGreater(result["words"], 0)
Expand Down
Loading