Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f975066c62 | |||
| c557c0ad2f | |||
| d086a7f198 |
@@ -1,6 +1,6 @@
|
|||||||
# Claude Code Working Guide
|
# Claude Code Working Guide
|
||||||
|
|
||||||
_Last synced: v2.8.79 (2026-11-06) — token-gated and LAN-bound dashboards work end to end; pool logins never strand a home._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
_Last synced: v2.8.80 (2026-11-06) — Shift+Enter new line in terminals; chat nested and continued lists._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
|
||||||
|
|
||||||
## Project mission
|
## Project mission
|
||||||
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
* @file MarkdownContent.tsx
|
* @file MarkdownContent.tsx
|
||||||
* @description Lightweight markdown renderer for conversation messages. Supports the
|
* @description Lightweight markdown renderer for conversation messages. Supports the
|
||||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
* fenced code blocks, ATX headings, ordered/unordered lists (nested by indent; a
|
||||||
|
* numbered list split by a blank line keeps counting from its own numbers),
|
||||||
|
* task lists, blockquotes,
|
||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||||
@@ -68,7 +70,7 @@ import { onLinkClick } from "../../lib/openInPreview";
|
|||||||
type Block =
|
type Block =
|
||||||
| { kind: "code"; lang: string; code: string }
|
| { kind: "code"; lang: string; code: string }
|
||||||
| { kind: "heading"; level: number; text: string }
|
| { kind: "heading"; level: number; text: string }
|
||||||
| { kind: "list"; ordered: boolean; items: string[] }
|
| { kind: "list"; ordered: boolean; start: number; items: string[] }
|
||||||
| { kind: "quote"; text: string }
|
| { kind: "quote"; text: string }
|
||||||
| { kind: "hr" }
|
| { kind: "hr" }
|
||||||
| {
|
| {
|
||||||
@@ -196,27 +198,35 @@ function parseBlocks(src: string): Block[] {
|
|||||||
if (ulMatch || olMatch) {
|
if (ulMatch || olMatch) {
|
||||||
const ordered = !!olMatch;
|
const ordered = !!olMatch;
|
||||||
const itemRe = ordered ? OL_RE : UL_RE;
|
const itemRe = ordered ? OL_RE : UL_RE;
|
||||||
|
const base = (olMatch ?? ulMatch)![1]!.length;
|
||||||
|
// The number the list starts at: a list broken by a blank line or a
|
||||||
|
// stray block still counts on from where the text says ("2.", "3.").
|
||||||
|
const start = olMatch ? Number(olMatch[2]) || 1 : 1;
|
||||||
const items: string[] = [];
|
const items: string[] = [];
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const m = lines[i]!.match(itemRe);
|
const m = lines[i]!.match(itemRe);
|
||||||
if (m) {
|
if (m && m[1]!.length <= base) {
|
||||||
items.push(m[3]!);
|
items.push(m[3]!);
|
||||||
i++;
|
i++;
|
||||||
|
// The item's own lines: indented text, and a list nested under it
|
||||||
|
// (indented deeper than this list's marker) — kept with its
|
||||||
|
// relative indent so the item renders it as a sub-list.
|
||||||
while (
|
while (
|
||||||
i < lines.length &&
|
i < lines.length &&
|
||||||
lines[i]!.trim() !== "" &&
|
lines[i]!.trim() !== "" &&
|
||||||
!lines[i]!.match(UL_RE) &&
|
/^\s+\S/.test(lines[i]!) &&
|
||||||
!lines[i]!.match(OL_RE) &&
|
(lines[i]!.match(/^\s*/)![0].length > base ||
|
||||||
/^\s+\S/.test(lines[i]!)
|
(!lines[i]!.match(UL_RE) && !lines[i]!.match(OL_RE)))
|
||||||
) {
|
) {
|
||||||
items[items.length - 1] += "\n" + lines[i]!.trim();
|
const nested = UL_RE.test(lines[i]!) || OL_RE.test(lines[i]!);
|
||||||
|
items[items.length - 1] += "\n" + (nested ? lines[i]! : lines[i]!.trim());
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blocks.push({ kind: "list", ordered, items });
|
blocks.push({ kind: "list", ordered, start, items });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,8 +534,27 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render a single list item, handling [ ] / [x] task list prefixes. */
|
/** Render a single list item: its text, then any list nested under it. */
|
||||||
function renderListItem(item: string, key: string): React.ReactNode {
|
function renderListItem(item: string, key: string): React.ReactNode {
|
||||||
|
const lines = item.split("\n");
|
||||||
|
const sub = lines.findIndex((l, n) => n > 0 && (UL_RE.test(l) || OL_RE.test(l)));
|
||||||
|
if (sub > 0) {
|
||||||
|
const rest = lines.slice(sub);
|
||||||
|
const indent = Math.min(...rest.filter((l) => l.trim()).map((l) => l.match(/^\s*/)![0].length));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderItemText(lines.slice(0, sub).join("\n"), key)}
|
||||||
|
<div className="mt-1">
|
||||||
|
<MarkdownContent text={rest.map((l) => l.slice(indent)).join("\n")} dense />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return renderItemText(item, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An item's own text, handling [ ] / [x] task list prefixes. */
|
||||||
|
function renderItemText(item: string, key: string): React.ReactNode {
|
||||||
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s);
|
||||||
if (taskMatch) {
|
if (taskMatch) {
|
||||||
const checked = taskMatch[1]!.toLowerCase() === "x";
|
const checked = taskMatch[1]!.toLowerCase() === "x";
|
||||||
@@ -594,6 +623,7 @@ export function MarkdownContent({ text, dense = false, prose = false }: Markdown
|
|||||||
return (
|
return (
|
||||||
<ol
|
<ol
|
||||||
key={idx}
|
key={idx}
|
||||||
|
start={b.start !== 1 ? b.start : undefined}
|
||||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||||
>
|
>
|
||||||
{b.items.map((item, i) => (
|
{b.items.map((item, i) => (
|
||||||
|
|||||||
@@ -89,6 +89,37 @@ describe("<MarkdownContent />", () => {
|
|||||||
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
expect(container.querySelectorAll("ol li")).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps one numbered list when its items carry indented bullets", () => {
|
||||||
|
const text = [
|
||||||
|
"Có 4 điểm:",
|
||||||
|
"",
|
||||||
|
"1. **yt-dlp**",
|
||||||
|
" - Hướng dẫn",
|
||||||
|
" - YouTube",
|
||||||
|
"2. **Nút Setup**",
|
||||||
|
" - site-packages",
|
||||||
|
"3. **Log khởi động**",
|
||||||
|
" Có thể bỏ dòng này.",
|
||||||
|
"4. **Nên kiểm tra**",
|
||||||
|
].join("\n");
|
||||||
|
const { container } = render(<MarkdownContent text={text} />);
|
||||||
|
const top = container.querySelectorAll(":scope > div > ol");
|
||||||
|
expect(top).toHaveLength(1);
|
||||||
|
expect(top[0]!.querySelectorAll(":scope > li")).toHaveLength(4);
|
||||||
|
expect(top[0]!.querySelectorAll("ul li")).toHaveLength(3);
|
||||||
|
expect(screen.getByText(/Có thể bỏ dòng này/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues the numbering of a list split by a blank line or a block", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MarkdownContent text={"1. first\n\n```\ncode\n```\n\n2. second\n3. third"} />
|
||||||
|
);
|
||||||
|
const lists = container.querySelectorAll("ol");
|
||||||
|
expect(lists).toHaveLength(2);
|
||||||
|
expect(lists[0]!.getAttribute("start")).toBeNull();
|
||||||
|
expect(lists[1]!.getAttribute("start")).toBe("2");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders blockquotes", () => {
|
it("renders blockquotes", () => {
|
||||||
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
const { container } = render(<MarkdownContent text={"> a quote"} />);
|
||||||
expect(container.querySelector("blockquote")).not.toBeNull();
|
expect(container.querySelector("blockquote")).not.toBeNull();
|
||||||
|
|||||||
@@ -634,6 +634,15 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
term.input(altArrow, true);
|
term.input(altArrow, true);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Shift+Enter: xterm sends a plain CR, so claude/codex SUBMIT the
|
||||||
|
// prompt. Send Ctrl+J (LF) instead — both prompts take it as a new
|
||||||
|
// line (Option+Enter is the other way, awkward on a Mac), while a
|
||||||
|
// shell still runs the line, as Enter did.
|
||||||
|
if (ev.key === "Enter" && ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
term.input("\n", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -634,6 +634,21 @@ describe("TerminalView", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends Shift+Enter as Ctrl+J, a new line in claude's prompt, not a submit", () => {
|
||||||
|
inputMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
|
const ev = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, cancelable: true });
|
||||||
|
expect(handler(ev)).toBe(false);
|
||||||
|
expect(ev.defaultPrevented).toBe(true);
|
||||||
|
expect(inputMock).toHaveBeenCalledWith("\n", true);
|
||||||
|
inputMock.mockClear();
|
||||||
|
// Plain Enter and Alt+Enter stay xterm's own.
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(true);
|
||||||
|
expect(handler(new KeyboardEvent("keydown", { key: "Enter", altKey: true }))).toBe(true);
|
||||||
|
expect(inputMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||||
// next row's left margin, and tmux never repaints a row it believes
|
// next row's left margin, and tmux never repaints a row it believes
|
||||||
|
|||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.79 (rebuild client to match)
|
UI build v2.8.80 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.79",
|
"version": "2.8.80",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.79",
|
"version": "2.8.80",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.79",
|
"version": "2.8.80",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user