diff --git a/packages/opencode/src/cli/cmd/tui/component/ticket-ref.tsx b/packages/opencode/src/cli/cmd/tui/component/ticket-ref.tsx new file mode 100644 index 000000000000..1a5e042770e5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/ticket-ref.tsx @@ -0,0 +1,149 @@ +// grunt-it: clickable hivemind ticket-ref (#229) component for assistant chat (#233). +// +// Renders `#229` colored + underlined inline. Hover lazily fetches the ticket detail +// from hivemind-api (localhost:7890) and shows a tooltip overlay with title + status + +// scope-preview. Click opens hivemind-ui at /tasks/ in the system browser. +// +// Why not markdown links: opentui's markdown component renders `[label](url)` literally +// (with brackets + URL visible) and has no hover/click hooks for inline tokens. We bypass +// it for ticket refs and render directly. + +import { createSignal, Show } from "solid-js" +import open from "open" +import { useTheme } from "../context/theme" +import { useHivemind } from "../context/hivemind" + +const HIVEMIND_UI_BASE = process.env.HIVEMIND_UI_BASE ?? "http://localhost:5173" +const HIVEMIND_API_BASE = process.env.HIVEMIND_API_BASE ?? "http://127.0.0.1:7890" + +type TicketDetail = { + id: number + title: string + scope?: string | null + status: string + priority: string + zone?: string | null + owner?: string | null +} + +const cache = new Map() +const inflight = new Map>() + +function fetchTicket(id: number): Promise { + if (cache.has(id)) return Promise.resolve(cache.get(id)!) + const existing = inflight.get(id) + if (existing) return existing + const p = (async () => { + try { + const controller = new AbortController() + const t = setTimeout(() => controller.abort(), 1500) + const res = await fetch(`${HIVEMIND_API_BASE}/api/tasks/${id}`, { signal: controller.signal }) + clearTimeout(t) + if (!res.ok) { + cache.set(id, null) + return null + } + const body = (await res.json()) as { task?: TicketDetail } | TicketDetail + const task = (body as { task?: TicketDetail }).task ?? (body as TicketDetail) + cache.set(id, task ?? null) + return task ?? null + } catch { + cache.set(id, null) + return null + } finally { + inflight.delete(id) + } + })() + inflight.set(id, p) + return p +} + +export function TicketRef(props: { id: number }) { + const { theme } = useTheme() + const hive = useHivemind() + const [hovered, setHovered] = createSignal(false) + const [detail, setDetail] = createSignal(cache.get(props.id)) + + function onEnter() { + setHovered(true) + if (detail() === undefined) { + void fetchTicket(props.id).then((d) => setDetail(d)) + } + } + + function onLeave() { + setHovered(false) + } + + function onClick() { + open(`${HIVEMIND_UI_BASE}/tasks/${props.id}`).catch(() => {}) + } + + return ( + + + #{props.id} + + + + loading #{props.id}...} + > + #{props.id}: (not found)} + > + {(d) => ( + <> + + #{d().id} {d().title} + + + {d().status} + {" · "} + {d().priority} + + {" · "} + zone={d().zone} + + + {" · "} + owner={d().owner} + + + + + {(d().scope ?? "").slice(0, 200)} + {(d().scope ?? "").length > 200 ? "..." : ""} + + + + click to open in hivemind-ui + + + )} + + + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 4a35e74f4115..d783d284ac64 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -55,6 +55,7 @@ import { useSDK } from "@tui/context/sdk" import { useEditorContext } from "@tui/context/editor" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" +import { TicketRef } from "../../component/ticket-ref" import { DialogMessage } from "./dialog-message" import type { PromptInfo } from "../../component/prompt/history" import { DialogConfirm } from "@tui/ui/dialog-confirm" @@ -1596,34 +1597,83 @@ function ReasoningHeader(props: { ) } -// grunt-it: linkify hivemind ticket refs (#229) inline so they render as clickable -// markdown links pointing at the local hivemind-ui. The markdown renderer styles them -// with theme.markdownLink (blue/cyan) and terminals supporting OSC 8 (iTerm2, kitty, etc.) -// make the link clickable to open the URL. Falls back to colored-but-non-clickable text -// in older terminals — still better than plain text. -// Refs hivemind #233. +// grunt-it: hivemind ticket refs (#229) in assistant text — render as interactive +// components with hover-preview + click-to-open-hivemind-ui. Refs #233. +// +// We split the text on the ticket-ref regex, render each non-matching segment through +// the markdown renderer (so prose formatting, code blocks, etc. keep working), and +// interleave components for the matches. Trade-off: inline markdown features +// that cross a #N boundary (rare — e.g. **bold #229 text**) won't span the split. Acceptable +// for typical prose. The wins: clean visual (no [label](url) artifact), real hover tooltip, +// proper click handler. const HIVEMIND_TICKET_RE = /(^|[^\w/#])#(\d{1,5})\b/g -const HIVEMIND_UI_BASE = process.env.HIVEMIND_UI_BASE ?? "http://localhost:5173" -function linkifyTicketRefs(text: string): string { - return text.replace(HIVEMIND_TICKET_RE, (_, prefix, id) => `${prefix}[#${id}](${HIVEMIND_UI_BASE}/tasks/${id})`) + +type TextSegment = { kind: "text"; text: string } | { kind: "ticket"; id: number } + +function splitOnTicketRefs(text: string): TextSegment[] { + const segments: TextSegment[] = [] + let lastIndex = 0 + HIVEMIND_TICKET_RE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = HIVEMIND_TICKET_RE.exec(text)) !== null) { + const [whole, prefix, idStr] = match + const start = match.index + prefix.length + if (start > lastIndex) { + segments.push({ kind: "text", text: text.slice(lastIndex, start) }) + } + segments.push({ kind: "ticket", id: Number(idStr) }) + lastIndex = match.index + whole.length + } + if (lastIndex < text.length) { + segments.push({ kind: "text", text: text.slice(lastIndex) }) + } + // Optimization: if no ticket matches, return a single text segment (caller can fast-path) + return segments.length > 0 ? segments : [{ kind: "text", text }] } function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const ctx = use() const { theme, syntax } = useTheme() + const segments = createMemo(() => splitOnTicketRefs(props.part.text.trim())) return ( - + s.kind === "ticket")} + fallback={ + + } + > + + + {(seg) => + seg.kind === "text" ? ( + + ) : ( + + ) + } + + + ) diff --git a/scripts/install-local.sh b/scripts/install-local.sh index de37d813cae3..71948eb16214 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -6,8 +6,31 @@ # Usage: # bash scripts/install-local.sh [version-label] # -# version-label defaults to "$(git describe --always --dirty)-local" so it's -# obvious from `gruntcode --version` that this is a local-build, not a release. +# Version label +# ------------- +# By default, the script stamps the binary with the SAME base version CI would +# stamp if you pushed the latest reachable `v*-grunt.*` tag — e.g. +# `1.15.10-grunt.7+local.5573d8375`. The part before `+` matches a real +# release; the part after `+` is semver build-metadata (ignored for ordering), +# so brew + version-comparisons + peers see the same release this binary is +# "based on", while `gruntcode --version` still flags it as a local build. +# +# Why this matters +# ---------------- +# Stamping local builds with `git describe --dirty` (the previous default) made +# them look version-ahead of any released gruntcode. If the worktree contained +# new drizzle migrations that the released brew binary doesn't have, the local +# build would apply them to ~/.local/share/opencode/opencode.db — and then the +# brew binary would crash on startup trying to re-apply migrations whose +# columns already exist. Today's incident: see today's activity log. +# +# Ahead-of-tag detection +# ---------------------- +# If HEAD is ahead of the latest grunt tag, the script prints a loud warning +# listing the new commits + any new migration files. It proceeds anyway (per +# Nik's policy choice) but the warning makes it impossible to miss the +# "you're building something CI hasn't released, the DB is now ahead of brew" +# trap. Pass `--ahead-ok` to suppress the warning when intentional. # # Pairs with the standard release flow: tag a release for distribution, but # install locally immediately so Nik isn't blocked on CI for his own bin. @@ -15,12 +38,86 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -VERSION_LABEL="${1:-$(git -C "$ROOT" describe --always --dirty)-local}" -echo "→ Building gruntcode from source (version=$VERSION_LABEL)..." +# --- parse args -------------------------------------------------------------- +AHEAD_OK=0 +EXPLICIT_LABEL="" +for arg in "$@"; do + case "$arg" in + --ahead-ok) AHEAD_OK=1 ;; + -h|--help) + sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) EXPLICIT_LABEL="$arg" ;; + esac +done + +# --- compute version label --------------------------------------------------- +if [ -n "$EXPLICIT_LABEL" ]; then + VERSION_LABEL="$EXPLICIT_LABEL" +else + # Latest grunt-* tag reachable from HEAD. Strip leading `v` to match CI's + # `${TAG#v}` (see .github/workflows/grunt-release.yml). + LATEST_TAG=$(git -C "$ROOT" describe --tags --abbrev=0 --match 'v*-grunt.*' 2>/dev/null || echo "") + if [ -z "$LATEST_TAG" ]; then + echo "ERROR: no reachable v*-grunt.* tag from HEAD; can't derive a CI-equivalent version." >&2 + echo " Pass an explicit version label as the first arg, or tag a release first." >&2 + exit 1 + fi + BASE_VERSION="${LATEST_TAG#v}" + + SHORT_SHA=$(git -C "$ROOT" rev-parse --short HEAD) + # Semver build metadata can only contain [0-9A-Za-z-] segments separated by '.'. + # We use `local.` (+ optional `.dirty`) to stay strictly valid. + BUILD_META="local.${SHORT_SHA}" + if [ -n "$(git -C "$ROOT" status --porcelain)" ]; then + BUILD_META="${BUILD_META}.dirty" + fi + VERSION_LABEL="${BASE_VERSION}+${BUILD_META}" +fi + +# --- ahead-of-tag warning ---------------------------------------------------- +if [ -z "$EXPLICIT_LABEL" ] && [ "$AHEAD_OK" -eq 0 ]; then + AHEAD_COUNT=$(git -C "$ROOT" rev-list --count "${LATEST_TAG}..HEAD" 2>/dev/null || echo 0) + if [ "$AHEAD_COUNT" -gt 0 ]; then + # New migration files since the tag = the migration-desync trap. + NEW_MIGRATIONS=$(git -C "$ROOT" diff --name-only --diff-filter=A "${LATEST_TAG}..HEAD" \ + -- 'packages/opencode/migration/**/migration.sql' 2>/dev/null || true) + + { + echo + echo "⚠️ HEAD is ${AHEAD_COUNT} commit(s) ahead of ${LATEST_TAG}." + echo " This local build contains code that CI has NOT released." + if [ -n "$NEW_MIGRATIONS" ]; then + echo + echo " NEW MIGRATIONS in this build (vs ${LATEST_TAG}):" + # shellcheck disable=SC2001 # sed is fine here; bash subst can't easily prefix every line + echo "$NEW_MIGRATIONS" | sed 's|^| • |' + echo + echo " After installing this binary, the brew-released gruntcode (${LATEST_TAG})" + echo " will likely CRASH on startup against your DB until you also tag+release" + echo " these migrations, or downgrade your DB. You've been warned." + else + echo " No new migrations detected — binary swap is schema-safe." + fi + echo + echo " Pass --ahead-ok to suppress this warning when intentional." + echo + } >&2 + fi +fi + +# --- build ------------------------------------------------------------------- +# Note: we deliberately do NOT set OPENCODE_RELEASE=1 here. That flag enables +# the post-build `gh release upload` step in packages/opencode/script/build.ts, +# which is for CI only. Setting it locally caused the build to fail with +# `bun: no matches found: ./dist/*.tar.gz` after the binary was produced — the +# binary still got copied to brew because the upload step ran last, but the +# error was noise we don't want. +echo "→ Building gruntcode from source (version=${VERSION_LABEL})..." cd "$ROOT/packages/opencode" OPENCODE_VERSION="$VERSION_LABEL" \ -OPENCODE_RELEASE=1 \ OPENCODE_CHANNEL=latest \ bun script/build.ts --single --skip-embed-web-ui 2>&1 | tail -5 || true @@ -31,7 +128,7 @@ if [ -z "$SRC_BIN" ] || [ ! -x "$SRC_BIN" ]; then exit 1 fi -# Find the current brew Cellar gruntcode dir +# --- install ----------------------------------------------------------------- CELLAR_BIN=$(find /opt/homebrew/Cellar/gruntcode -name gruntcode -type f 2>/dev/null | sort -r | head -1) if [ -z "$CELLAR_BIN" ]; then echo "ERROR: no brew-installed gruntcode found. Run 'brew install grunt-it/tap/gruntcode' first." >&2