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. - 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. - `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 ## 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. 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`. 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. 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 em([MDInline])
case image(src: String, alt: String) case image(src: String, alt: String)
case link(href: String, [MDInline]) case link(href: String, [MDInline])
case mark(MDMark, [MDInline])
case lineBreak 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 { struct MDItem: Sendable {
var choice: String? var choice: String?
var choiceIndex = 0 var choiceIndex = 0
@@ -301,6 +314,14 @@ private func cells(_ line: String) -> [String] {
} }
private func inline(_ src: String) -> [MDInline] { 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 out: [MDInline] = []
var rest = src var rest = src
while !rest.isEmpty { while !rest.isEmpty {
@@ -314,13 +335,13 @@ private func inline(_ src: String) -> [MDInline] {
if let code = g[1] { if let code = g[1] {
out.append(.code(code)) out.append(.code(code))
} else if let strong = g[2] ?? g[3] { } 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] { } else if let em = g[4] ?? g[5] {
out.append(.em(inline(em))) out.append(.em(spans(em)))
} else if let alt = g[6] { } else if let alt = g[6] {
out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt)) out.append(.image(src: trimmed(g[7] ?? g[8]!), alt: alt))
} else if let text = g[9] { } 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] { } else if let bare = g[11] {
out.append(.link(href: bare, [.text(bare)])) 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 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 httpLink = Re(#"^https?://"#, caseless: true)
private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#) private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#)
private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#) private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#)
@@ -136,6 +154,10 @@ struct InlineRenderer {
} }
case .link(let href, let inner): case .link(let href, let inner):
link(href, inner, style, &out) 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 { GridRow {
ForEach(Array(head.enumerated()), id: \.offset) { _, cell in ForEach(Array(head.enumerated()), id: \.offset) { _, cell in
cellView([.strong(cell)]) cellView([.strong(cell)])
.background(Palette.surface) .background(tint(cell) ?? Palette.surface)
} }
} }
ForEach(Array(rows.enumerated()), id: \.offset) { _, row in ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
GridRow { GridRow {
ForEach(Array(row.enumerated()), id: \.offset) { _, cell in ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
cellView(cell) cellView(cell)
.background(tint(cell) ?? .clear)
} }
} }
} }
@@ -435,6 +458,13 @@ private struct TableBlock: View {
.overlay(Rectangle().stroke(Palette.border)) .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 { private func cellView(_ cell: [MDInline]) -> some View {
InlineView(inlines: cell, ctx: ctx) InlineView(inlines: cell, ctx: ctx)
.padding(.horizontal, 8) .padding(.horizontal, 8)
+41 -4
View File
@@ -245,10 +245,47 @@ function cells(line) {
} }
function table(rows, opts) { function table(rows, opts) {
const [head, ...body] = rows; 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<>().,;:!?'"])/; const inlineToken = /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src, opts) { 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 = []; const out = [];
let rest = src; let rest = src;
while (rest.length > 0) { while (rest.length > 0) {
@@ -265,16 +302,16 @@ function inline(src, opts) {
out.push(h("code", {}, code)); out.push(h("code", {}, code));
} }
else if (strong1 !== undefined || strong2 !== undefined) { 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) { 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) { else if (imgAlt !== undefined) {
out.push(image((imgAngled ?? imgSrc).trim(), imgAlt, opts.taskId)); out.push(image((imgAngled ?? imgSrc).trim(), imgAlt, opts.taskId));
} }
else if (linkText !== undefined) { else if (linkText !== undefined) {
out.push(link(linkHref, inline(linkText, opts))); out.push(link(linkHref, spans(linkText, opts)));
} }
else if (bare !== undefined) { else if (bare !== undefined) {
out.push(link(bare, [document.createTextNode(bare)])); 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( h(
"table", "table",
{}, {},
h("thead", {}, h("tr", {}, ...head!.map((c) => h("th", {}, ...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", {}, ...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 = const inlineToken =
/`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/; /`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?<![\w])_([^_\s][^_]*?)_(?![\w])|!\[([^\]]*)\]\((?:<([^>]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/;
function inline(src: string, opts: Options): Node[] { 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[] = []; const out: Node[] = [];
let rest = src; let rest = src;
while (rest.length > 0) { while (rest.length > 0) {
@@ -308,13 +349,13 @@ function inline(src: string, opts: Options): Node[] {
if (code !== undefined) { if (code !== undefined) {
out.push(h("code", {}, code)); out.push(h("code", {}, code));
} else if (strong1 !== undefined || strong2 !== undefined) { } 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) { } 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) { } else if (imgAlt !== undefined) {
out.push(image((imgAngled ?? imgSrc!).trim(), imgAlt, opts.taskId)); out.push(image((imgAngled ?? imgSrc!).trim(), imgAlt, opts.taskId));
} else if (linkText !== undefined) { } else if (linkText !== undefined) {
out.push(link(linkHref!, inline(linkText, opts))); out.push(link(linkHref!, spans(linkText, opts)));
} else if (bare !== undefined) { } else if (bare !== undefined) {
out.push(link(bare, [document.createTextNode(bare)])); out.push(link(bare, [document.createTextNode(bare)]));
} }
+24
View File
@@ -850,6 +850,30 @@ button.compact.danger {
background: var(--surface); 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 { .msg.user {
align-self: flex-end; align-self: flex-end;
max-width: 85%; max-width: 85%;