input coloring

This commit is contained in:
Ian Gulliver
2026-10-17 08:53:21 -07:00
parent 3e537c827d
commit b1eb595af1
8 changed files with 171 additions and 15 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ These are requirements, not preferences. Changing one needs the owner's agreemen
- Never deploy unless the owner asks for it in the current turn. A finished change is reported undeployed; the owner deploys it, or asks you to. Never run `go install ./cmd/byre` or restart the service on your own initiative, not even to check that a change works.
- `docs/operations.md` describes the layout, data, service and APIs; keep it current with every change.
- Never add tests. byre has no test suite; check a change with `go vet ./...` and `go generate ./web` (which type-checks the UI).
- Never add tests. byre has no test suite; check a change with `go vet ./...`, `go generate ./web` (which type-checks the UI) and, when `mac/` changed, `swift build --package-path mac`.
- The web UI (`web/src/`) and the macOS app (`mac/Sources/Byre/`) are two clients of the same views. A change to what a client shows or does, such as Markdown rendering, goes into both in the same task; never leave one client behind.
## Deploying
+2
View File
@@ -259,6 +259,8 @@ There is no snoozing. The `snoozed_until` and `snooze_until` columns are still i
Every Markdown view (inbox items and the transcript) renders a line `<details><summary>Title</summary>` (or `<details>` with the `<summary>` on the next line), Markdown content, and a line `</details>` as a collapsed section (`web/src/markdown.ts`; sections nest) showing only the title; session guidance and the `ask_human` schema tell agents to put long supporting detail in them.
A status mark that is the whole of a table cell, paragraph line, list item or heading, optionally in bold, renders coloured (`marks` in `web/src/markdown.ts` and `mac/Sources/Byre/Markdown.swift`): checks (✓, ✅ and the like) in `--ok`, `~`, ≈ and ⚠ in `--warn`, and `x`, ×, ✗, ❌ and the like in `--danger`. A table cell holding one also gets the matching `-tint` background. A mark inside other text, or in code, stays plain.
Markdown images (`![alt](src)`, or `![alt](<src>)`; the plain form also accepts spaces, since worktree paths under `Application Support` have one) render inline, linking to the full image. A `src` that is an absolute path, a path relative to the worktree or a `file://` URL is loaded from `GET /api/tasks/{taskId}/image?path=` for the item's or event's task, which refuses (403) a path that resolves outside the task's worktree, so the `ask_human` description tells agents to copy a scratch image into the worktree first; a `data:image/` URL of PNG, JPEG, GIF or WebP is used as is; any other scheme, `http(s)` included, shows as a link with the alt text and is never fetched. An image that fails to load is replaced by "image not available: <src>". An inbox `image` attachment renders the same way from its `image` (data URL) or `path`.
A code block fenced as `mermaid`, and an inbox `diagram` attachment, render as a Mermaid diagram (`web/src/mermaid.ts`). The library is vendored: `web/package.json` pins `mermaid` as a devDependency, and `go generate ./web` copies `node_modules/mermaid/dist/mermaid.min.js` and its LICENSE into `web/vendor/`, which is committed and embedded. To upgrade, change the pinned version, `npm --prefix web install`, and regenerate. Nothing loads it until the first diagram is drawn; that injects one `<script src="/vendor/mermaid.min.js">` and initializes Mermaid with `securityLevel: "strict"` and the `base` theme, coloured from the custom properties in `web/style.css` as they resolve at that moment (so a light/dark switch applies from the next page load). Each diagram shows its source until its SVG is ready; source Mermaid rejects stays as source with "diagram not rendered: <error>" under it. The endpoint (`internal/server/images.go`) serves PNG, JPEG, GIF, WebP and SVG by extension, resolves symlinks on both the worktree and the file and answers 403 for anything outside the worktree, 404 for a missing file, 400 for another extension or a non-file, and 409 once the task is closed (its worktree is gone). Responses carry `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, so an SVG opened directly runs no script, and `Cache-Control: no-cache` with `Last-Modified`, so a changed file is refetched.
+24 -3
View File
@@ -7,9 +7,22 @@ indirect enum MDInline: Sendable {
case em([MDInline])
case image(src: String, alt: String)
case link(href: String, [MDInline])
case mark(MDMark, [MDInline])
case lineBreak
}
enum MDMark: Sendable {
case ok, warn, danger
}
private let marks: [String: MDMark] = [
"✓": .ok, "✔": .ok, "✔️": .ok, "✅": .ok, "☑": .ok, "☑️": .ok,
"~": .warn, "≈": .warn, "〜": .warn, "⚠": .warn, "⚠️": .warn, "🟡": .warn,
"x": .danger, "X": .danger, "×": .danger, "✗": .danger, "✘": .danger, "✕": .danger, "✖": .danger, "✖️": .danger, "❌": .danger,
]
private let boldWrap = Re(#"^(\*\*|__)(.*)\1$"#)
struct MDItem: Sendable {
var choice: String?
var choiceIndex = 0
@@ -301,6 +314,14 @@ private func cells(_ line: String) -> [String] {
}
private func inline(_ src: String) -> [MDInline] {
let t = trimmed(src)
if let mark = marks[trimmed(boldWrap.exec(t)?[2] ?? t)] {
return [.mark(mark, spans(src))]
}
return spans(src)
}
private func spans(_ src: String) -> [MDInline] {
var out: [MDInline] = []
var rest = src
while !rest.isEmpty {
@@ -314,13 +335,13 @@ private func inline(_ src: String) -> [MDInline] {
if let code = g[1] {
out.append(.code(code))
} else if let strong = g[2] ?? g[3] {
out.append(.strong(inline(strong)))
out.append(.strong(spans(strong)))
} else if let em = g[4] ?? g[5] {
out.append(.em(inline(em)))
out.append(.em(spans(em)))
} else if let alt = g[6] {
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
} else if let text = g[9] {
out.append(.link(href: g[10]!, inline(text)))
out.append(.link(href: g[10]!, spans(text)))
} else if let bare = g[11] {
out.append(.link(href: bare, [.text(bare)]))
}
+31 -1
View File
@@ -54,6 +54,24 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct
return .handled
}
extension MDMark {
var color: Color {
switch self {
case .ok: Palette.ok
case .warn: Palette.warn
case .danger: Palette.danger
}
}
var tint: Color {
switch self {
case .ok: Palette.okTint
case .warn: Palette.warnTint
case .danger: Palette.dangerTint
}
}
}
private let httpLink = Re(#"^https?://"#, caseless: true)
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
@@ -136,6 +154,10 @@ struct InlineRenderer {
}
case .link(let href, let inner):
link(href, inner, style, &out)
case .mark(let mark, let inner):
var s = style
s.color = mark.color
render(inner, s, &out)
}
}
}
@@ -421,13 +443,14 @@ private struct TableBlock: View {
GridRow {
ForEach(Array(head.enumerated()), id: \.offset) { _, cell in
cellView([.strong(cell)])
.background(Palette.surface)
.background(tint(cell) ?? Palette.surface)
}
}
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
cellView(cell)
.background(tint(cell) ?? .clear)
}
}
}
@@ -435,6 +458,13 @@ private struct TableBlock: View {
.overlay(Rectangle().stroke(Palette.border))
}
private func tint(_ cell: [MDInline]) -> Color? {
guard cell.count == 1, case .mark(let mark, _) = cell[0] else {
return nil
}
return mark.tint
}
private func cellView(_ cell: [MDInline]) -> some View {
InlineView(inlines: cell, ctx: ctx)
.padding(.horizontal, 8)
+41 -4
View File
@@ -245,10 +245,47 @@ function cells(line) {
}
function table(rows, opts) {
const [head, ...body] = rows;
return h("div", { class: "md-table" }, h("table", {}, h("thead", {}, h("tr", {}, ...head.map((c) => h("th", {}, ...inline(c, opts))))), h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", {}, ...inline(c, opts))))))));
return h("div", { class: "md-table" }, h("table", {}, h("thead", {}, h("tr", {}, ...head.map((c) => h("th", markClass(c), ...inline(c, opts))))), h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", markClass(c), ...inline(c, opts))))))));
}
const marks = {
"✓": "ok",
"✔": "ok",
"✔️": "ok",
"✅": "ok",
"☑": "ok",
"☑️": "ok",
"~": "warn",
"≈": "warn",
"〜": "warn",
"⚠": "warn",
"⚠️": "warn",
"🟡": "warn",
x: "danger",
X: "danger",
"×": "danger",
"✗": "danger",
"✘": "danger",
"✕": "danger",
"✖": "danger",
"✖️": "danger",
"❌": "danger",
};
function markOf(src) {
return marks[src.trim().replace(/^(\*\*|__)(.*)\1$/, "$2").trim()];
}
function markClass(cell) {
const mark = markOf(cell);
return mark ? { class: `md-mark ${mark}` } : {};
}
const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src, opts) {
const mark = markOf(src);
if (mark) {
return [h("span", { class: `md-mark ${mark}` }, ...spans(src, opts))];
}
return spans(src, opts);
}
function spans(src, opts) {
const out = [];
let rest = src;
while (rest.length > 0) {
@@ -265,16 +302,16 @@ function inline(src, opts) {
out.push(h("code", {}, code));
}
else if (strong1 !== undefined || strong2 !== undefined) {
out.push(h("strong", {}, ...inline(strong1 ?? strong2, opts)));
out.push(h("strong", {}, ...spans(strong1 ?? strong2, opts)));
}
else if (em1 !== undefined || em2 !== undefined) {
out.push(h("em", {}, ...inline(em1 ?? em2, opts)));
out.push(h("em", {}, ...spans(em1 ?? em2, opts)));
}
else if (imgAlt !== undefined) {
out.push(image((imgAngled ?? imgSrc).trim(), imgAlt, opts.taskId));
}
else if (linkText !== undefined) {
out.push(link(linkHref, inline(linkText, opts)));
out.push(link(linkHref, spans(linkText, opts)));
}
else if (bare !== undefined) {
out.push(link(bare, [document.createTextNode(bare)]));
+1 -1
View File
File diff suppressed because one or more lines are too long
+46 -5
View File
@@ -283,16 +283,57 @@ function table(rows: string[][], opts: Options): HTMLElement {
h(
"table",
{},
h("thead", {}, h("tr", {}, ...head!.map((c) => h("th", {}, ...inline(c, opts))))),
h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", {}, ...inline(c, opts)))))),
h("thead", {}, h("tr", {}, ...head!.map((c) => h("th", markClass(c), ...inline(c, opts))))),
h("tbody", {}, ...body.map((r) => h("tr", {}, ...r.map((c) => h("td", markClass(c), ...inline(c, opts)))))),
),
);
}
const marks: Record<string, string> = {
"✓": "ok",
"✔": "ok",
"✔️": "ok",
"✅": "ok",
"☑": "ok",
"☑️": "ok",
"~": "warn",
"≈": "warn",
"〜": "warn",
"⚠": "warn",
"⚠️": "warn",
"🟡": "warn",
x: "danger",
X: "danger",
"×": "danger",
"✗": "danger",
"✘": "danger",
"✕": "danger",
"✖": "danger",
"✖️": "danger",
"❌": "danger",
};
function markOf(src: string): string | undefined {
return marks[src.trim().replace(/^(\*\*|__)(.*)\1$/, "$2").trim()];
}
function markClass(cell: string): { class?: string } {
const mark = markOf(cell);
return mark ? { class: `md-mark ${mark}` } : {};
}
const inlineToken =
/`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src: string, opts: Options): Node[] {
const mark = markOf(src);
if (mark) {
return [h("span", { class: `md-mark ${mark}` }, ...spans(src, opts))];
}
return spans(src, opts);
}
function spans(src: string, opts: Options): Node[] {
const out: Node[] = [];
let rest = src;
while (rest.length > 0) {
@@ -308,13 +349,13 @@ function inline(src: string, opts: Options): Node[] {
if (code !== undefined) {
out.push(h("code", {}, code));
} else if (strong1 !== undefined || strong2 !== undefined) {
out.push(h("strong", {}, ...inline(strong1 ?? strong2!, opts)));
out.push(h("strong", {}, ...spans(strong1 ?? strong2!, opts)));
} else if (em1 !== undefined || em2 !== undefined) {
out.push(h("em", {}, ...inline(em1 ?? em2!, opts)));
out.push(h("em", {}, ...spans(em1 ?? em2!, opts)));
} else if (imgAlt !== undefined) {
out.push(image((imgAngled ?? imgSrc!).trim(), imgAlt, opts.taskId));
} else if (linkText !== undefined) {
out.push(link(linkHref!, inline(linkText, opts)));
out.push(link(linkHref!, spans(linkText, opts)));
} else if (bare !== undefined) {
out.push(link(bare, [document.createTextNode(bare)]));
}
+24
View File
@@ -850,6 +850,30 @@ button.compact.danger {
background: var(--surface);
}
.md-mark.ok {
color: var(--ok);
}
.md-mark.warn {
color: var(--warn);
}
.md-mark.danger {
color: var(--danger);
}
.md .md-mark.ok:is(td, th) {
background: var(--ok-tint);
}
.md .md-mark.warn:is(td, th) {
background: var(--warn-tint);
}
.md .md-mark.danger:is(td, th) {
background: var(--danger-tint);
}
.msg.user {
align-self: flex-end;
max-width: 85%;