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
149 changes: 149 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/ticket-ref.tsx
Original file line number Diff line number Diff line change
@@ -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/<id> 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<number, TicketDetail | null>()
const inflight = new Map<number, Promise<TicketDetail | null>>()

function fetchTicket(id: number): Promise<TicketDetail | null> {
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<TicketDetail | null | undefined>(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 (
<box flexDirection="column" flexShrink={0}>
<text
fg={theme.markdownLink ?? theme.primary}
attributes={1}
onMouseOver={onEnter}
onMouseOut={onLeave}
onMouseUp={onClick}
>
#{props.id}
</text>
<Show when={hovered() && hive.state.apiOnline}>
<box
position="absolute"
backgroundColor={theme.backgroundPanel}
border={true}
borderColor={theme.border}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
zIndex={2000}
maxWidth={60}
>
<Show
when={detail() !== undefined}
fallback={<text fg={theme.textMuted}>loading #{props.id}...</text>}
>
<Show
when={detail()}
fallback={<text fg={theme.textMuted}>#{props.id}: (not found)</text>}
>
{(d) => (
<>
<text fg={theme.text}>
<b>#{d().id} {d().title}</b>
</text>
<text fg={theme.textMuted}>
<span>{d().status}</span>
{" · "}
<span>{d().priority}</span>
<Show when={d().zone}>
{" · "}
<span>zone={d().zone}</span>
</Show>
<Show when={d().owner}>
{" · "}
<span>owner={d().owner}</span>
</Show>
</text>
<Show when={d().scope}>
<text fg={theme.textMuted} wrapMode="word">
{(d().scope ?? "").slice(0, 200)}
{(d().scope ?? "").length > 200 ? "..." : ""}
</text>
</Show>
<text fg={theme.textMuted}>
<span>click to open in hivemind-ui</span>
</text>
</>
)}
</Show>
</Show>
</box>
</Show>
</box>
)
}
88 changes: 69 additions & 19 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
// <TicketRef> 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 <TicketRef> 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 (
<Show when={props.part.text.trim()}>
<box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={linkifyTicketRefs(props.part.text.trim())}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
<Show
when={segments().some((s) => s.kind === "ticket")}
fallback={
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={props.part.text.trim()}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
}
>
<box flexDirection="row" flexWrap="wrap">
<For each={segments()}>
{(seg) =>
seg.kind === "text" ? (
<markdown
syntaxStyle={syntax()}
streaming={true}
internalBlockMode="top-level"
content={seg.text}
tableOptions={{ style: "grid" }}
conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/>
) : (
<TicketRef id={seg.id} />
)
}
</For>
</box>
</Show>
</box>
</Show>
)
Expand Down
109 changes: 103 additions & 6 deletions scripts/install-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,118 @@
# 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.

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.<sha>` (+ 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

Expand All @@ -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
Expand Down
Loading