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 web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"highlight.js": "^11.11.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
Expand Down
90 changes: 29 additions & 61 deletions web/src/HeroCodeBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,28 @@
// Lifted from flamecast-agents' InteractiveHero. Custom 4-token tokenizer
// (keyword / string / fn / punct) so we don't pull in highlight.js or shiki
// for a single block. Lines can be made clickable by passing
// `highlightedLines` + `onLineClick`; pair with `activeLine` to render
// the currently-selected state.
import hljs from "highlight.js/lib/core"
import typescript from "highlight.js/lib/languages/typescript"
import xml from "highlight.js/lib/languages/xml"

const KW = /\b(import|export|from|const|let|var|async|await|function|return|type|interface)\b/g
const STR = /(["'`])(?:(?!\1).)*?\1/g
const COMMENT = /(\/\/.*$|#.*$)/gm
const FN = /\b([a-zA-Z_]\w*)\s*(?=\()/g
hljs.registerLanguage("typescript", typescript)
hljs.registerLanguage("xml", xml)

function tokenizeLine(line: string): { type: string; text: string }[] {
type Span = { start: number; end: number; type: string }
const spans: Span[] = []
// Strings first so later passes (comment / keyword / fn) can skip
// matches that start inside a string. Without this, the "//" in
// a URL like "https://example.com" gets misread as a line comment.
for (const m of line.matchAll(STR)) spans.push({ start: m.index!, end: m.index! + m[0].length, type: "string" })
for (const m of line.matchAll(COMMENT)) {
if (!spans.some(s => m.index! >= s.start && m.index! < s.end))
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "punct" })
}
for (const m of line.matchAll(KW)) {
if (!spans.some(s => m.index! >= s.start && m.index! < s.end))
spans.push({ start: m.index!, end: m.index! + m[0].length, type: "keyword" })
}
for (const m of line.matchAll(FN)) {
if (!spans.some(s => m.index! >= s.start && m.index! < s.end))
spans.push({ start: m.index!, end: m.index! + m[1].length, type: "fn" })
}
spans.sort((a, b) => a.start - b.start)
const tokens: { type: string; text: string }[] = []
let pos = 0
for (const s of spans) {
// Defensive: drop spans that overlap a previously emitted one.
// The inside-string check above usually catches this, but this
// guarantees no slice of input is ever emitted twice.
if (s.start < pos) continue
if (s.start > pos) tokens.push({ type: "plain", text: line.slice(pos, s.start) })
tokens.push({ type: s.type, text: line.slice(s.start, s.end) })
pos = s.end
}
if (pos < line.length) tokens.push({ type: "plain", text: line.slice(pos) })
return tokens.length ? tokens : [{ type: "plain", text: line }]
}

function CodeToken({ type, text }: { type: string; text: string }) {
const classMap: Record<string, string> = {
keyword: "ih-tok-keyword",
string: "ih-tok-string",
fn: "ih-tok-fn",
punct: "ih-tok-punct",
plain: "",
}
const cls = classMap[type] || ""
return cls ? <span className={cls}>{text}</span> : <>{text}</>
// Highlight the whole snippet once, then split on real newlines.
// hljs spans for tsx don't cross newlines for this snippet, but we
// rebalance just in case so each line is a self-contained HTML fragment.
function highlightLines(code: string): string[] {
const html = hljs.highlight(code, { language: "typescript" }).value
const rawLines = html.split("\n")
const openStack: string[] = []
return rawLines.map(line => {
const prefix = openStack.map(tag => tag).join("")
const re = /<span class="[^"]*">|<\/span>/g
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
if (m[0] === "</span>") openStack.pop()
else openStack.push(m[0])
}
const suffix = openStack.map(() => "</span>").join("")
return prefix + line + suffix
})
}

export function HeroCodeBlock({
Expand All @@ -69,7 +38,7 @@ export function HeroCodeBlock({
activeLine?: number | null
onLineClick?: (i: number) => void
}) {
const lines = code.split("\n")
const lines = highlightLines(code)
return (
<div className="ih-code-col">
<div className="ih-code-head">
Expand All @@ -84,7 +53,7 @@ export function HeroCodeBlock({
</span>
) : null}
</div>
<pre className="ih-code-body">
<pre className="ih-code-body hljs">
<code>
{lines.map((line, i) => {
const isHighlighted = highlightedLines?.has(i) ?? false
Expand All @@ -102,11 +71,10 @@ export function HeroCodeBlock({
onClick={isHighlighted ? () => onLineClick?.(i) : undefined}
>
<span className="ih-line-num">{i + 1}</span>
<span className="ih-line-text">
{line ? tokenizeLine(line).map((tok, j) => (
<CodeToken key={j} type={tok.type} text={tok.text} />
)) : "\n"}
</span>
<span
className="ih-line-text"
dangerouslySetInnerHTML={{ __html: line || "\n" }}
/>
</div>
)
})}
Expand Down
40 changes: 26 additions & 14 deletions web/src/InteractiveContextHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,54 @@ import { HeroCodeBlock } from "./HeroCodeBlock"
export const CODE = `import { createAgentRuntime, createAiGatewayInfer, render } from "@flamecast/agentjsx"
import {
Agent, Block, Messages,
Workspace, Skills, McpServer,
Todo, Errors, GitState,
Workspace, Skills, McpServer, Todo,
Compact,
} from "@flamecast/agentjsx/components"
import { NodeContext } from "@flamecast/agentjsx/node"

const agent = createAgentRuntime({
infer: createAiGatewayInfer({ model: "anthropic/claude-sonnet-4-6" }),
platform: NodeContext.layer,
context: () => render(
<Agent>
<Block name="role">You are a coding assistant.</Block>
<Workspace root="./" />
<Skills root="./skills" />
<McpServer name="deepwiki" url="https://mcp.deepwiki.com/mcp" />
<McpServer
name="linear"
url="https://mcp.linear.app/mcp"
headers={{ Authorization: \`Bearer \${process.env.LINEAR_API_KEY}\` }}
/>
<Todo />
<Errors />
<GitState />
<McpServer name="linear" url="https://mcp.smithery.run/linear" />
<Messages />
<Compact strategy="summary" threshold={4000}>
<Messages />
</Compact>
</Agent>
),
})

await agent.send("Fix the highest-priority bug in Linear and open a PR.")`
await agent.run("Find the latest bug in Linear and open a PR fixing it.")`

// Each JSX line maps to one slice in the rendered context panel.
// "fs", "skill", and "mcp" are capability components: clicking them
// lights up BOTH the tool pills they install AND the system block
// light up BOTH the tool pills they install AND the system block
// they contribute (when they contribute one). "role" is a manual
// Block; "messages" comes from the event log.
type Slice = "role" | "fs" | "skill" | "mcp" | "messages"

const LINE_SLICE: Record<number, Slice> = {
7: "role", // <Block name="role">
8: "fs", // <Workspace root="./" />
9: "skill", // <Skills root="./skills" />
10: "mcp", // <McpServer name="linear" url="..." />
11: "messages", // <Messages from={events} />
16: "messages", // await agent.send("...")
13: "role", // <Block name="role">
14: "fs", // <Workspace root="./" />
15: "skill", // <Skills root="./skills" />
16: "mcp", // <McpServer name="deepwiki" ... />
17: "mcp", // <McpServer name="linear" ...
18: "mcp",
19: "mcp",
20: "mcp",
21: "mcp", // />
24: "messages", // <Messages />
30: "messages", // await agent.run("...")
}

const HIGHLIGHTED = new Set(Object.keys(LINE_SLICE).map(Number))
Expand Down
54 changes: 43 additions & 11 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -676,19 +676,51 @@ button.ih-code-tab:hover { color: var(--fg-70); }
}

/* Syntax tokens — dark mode defaults; light mode overrides match flamecast. */
.ih-tok-keyword { color: #c084fc; }
.ih-tok-string { color: #86efac; }
.ih-tok-fn { color: #93c5fd; }
.ih-tok-punct { color: var(--fg-40); }
.ih-tok-keyword,
.ih-code-body .hljs-keyword,
.ih-code-body .hljs-literal,
.ih-code-body .hljs-built_in { color: #c084fc; }
.ih-tok-string,
.ih-code-body .hljs-string,
.ih-code-body .hljs-regexp,
.ih-code-body .hljs-attr { color: #86efac; }
.ih-tok-fn,
.ih-code-body .hljs-title,
.ih-code-body .hljs-title.function_,
.ih-code-body .hljs-name { color: #93c5fd; }
.ih-tok-punct,
.ih-code-body .hljs-comment,
.ih-code-body .hljs-punctuation { color: var(--fg-40); }
.ih-code-body .hljs-number { color: #fbbf24; }
.ih-code-body .hljs-variable,
.ih-code-body .hljs-property,
.ih-code-body .hljs-params { color: var(--fg-1); }
.ih-code-body .hljs-tag { color: var(--fg-40); }

@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) .ih-tok-keyword { color: #7c3aed; }
:root:not([data-theme="dark"]) .ih-tok-string { color: #16a34a; }
:root:not([data-theme="dark"]) .ih-tok-fn { color: #2563eb; }
}
:root[data-theme="light"] .ih-tok-keyword { color: #7c3aed; }
:root[data-theme="light"] .ih-tok-string { color: #16a34a; }
:root[data-theme="light"] .ih-tok-fn { color: #2563eb; }
:root:not([data-theme="dark"]) .ih-tok-keyword,
:root:not([data-theme="dark"]) .ih-code-body .hljs-keyword,
:root:not([data-theme="dark"]) .ih-code-body .hljs-literal,
:root:not([data-theme="dark"]) .ih-code-body .hljs-built_in { color: #7c3aed; }
:root:not([data-theme="dark"]) .ih-tok-string,
:root:not([data-theme="dark"]) .ih-code-body .hljs-string,
:root:not([data-theme="dark"]) .ih-code-body .hljs-attr { color: #16a34a; }
:root:not([data-theme="dark"]) .ih-tok-fn,
:root:not([data-theme="dark"]) .ih-code-body .hljs-title,
:root:not([data-theme="dark"]) .ih-code-body .hljs-title.function_,
:root:not([data-theme="dark"]) .ih-code-body .hljs-name { color: #2563eb; }
}
:root[data-theme="light"] .ih-tok-keyword,
:root[data-theme="light"] .ih-code-body .hljs-keyword,
:root[data-theme="light"] .ih-code-body .hljs-literal,
:root[data-theme="light"] .ih-code-body .hljs-built_in { color: #7c3aed; }
:root[data-theme="light"] .ih-tok-string,
:root[data-theme="light"] .ih-code-body .hljs-string,
:root[data-theme="light"] .ih-code-body .hljs-attr { color: #16a34a; }
:root[data-theme="light"] .ih-tok-fn,
:root[data-theme="light"] .ih-code-body .hljs-title,
:root[data-theme="light"] .ih-code-body .hljs-title.function_,
:root[data-theme="light"] .ih-code-body .hljs-name { color: #2563eb; }

@media (max-width: 540px) {
.ih-code-body { font-size: 11px; }
Expand Down
Loading