diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index cb5c45063f..e1ff4241c9 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -28,40 +28,98 @@ jobs: const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" - const stalePrs = [] core.info(`Dry run mode: ${dryRun}`) + core.info(`Cutoff date: ${cutoff.toISOString()}`) - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - per_page: 100, - sort: "updated", - direction: "asc", - }) + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + author { + login + } + createdAt + commits(last: 1) { + nodes { + commit { + committedDate + } + } + } + comments(last: 1) { + nodes { + createdAt + } + } + reviews(last: 1) { + nodes { + createdAt + } + } + } + } + } + } + ` - for (const pr of prs) { - const lastUpdated = new Date(pr.updated_at) - if (lastUpdated > cutoff) { - core.info(`PR ${pr.number} is fresh`) - continue + const allPrs = [] + let cursor = null + let hasNextPage = true + + while (hasNextPage) { + const result = await github.graphql(query, { + owner, + repo, + cursor, + }) + + allPrs.push(...result.repository.pullRequests.nodes) + hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage + cursor = result.repository.pullRequests.pageInfo.endCursor + } + + core.info(`Found ${allPrs.length} open pull requests`) + + const stalePrs = allPrs.filter((pr) => { + const dates = [ + new Date(pr.createdAt), + pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, + pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, + pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, + ].filter((d) => d !== null) + + const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] + + if (!lastActivity || lastActivity > cutoff) { + core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) + return false } - stalePrs.push(pr) - } + core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) + return true + }) if (!stalePrs.length) { core.info("No stale pull requests found.") return } + core.info(`Found ${stalePrs.length} stale pull requests`) + for (const pr of stalePrs) { const issue_number = pr.number const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) continue } @@ -79,5 +137,5 @@ jobs: state: "closed", }) - core.info(`Closed PR #${issue_number} from ${pr.user.login}`) + core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) } diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index bb4db70882..cc16d81844 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -58,10 +58,8 @@ jobs: # Build with fakeHash to trigger hash mismatch and reveal correct hash nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true - HASH="$(grep -E 'got:\s+sha256-' "$BUILD_LOG" | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)" - if [ -z "$HASH" ]; then - HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | sed -E 's/.*got:\s+(sha256-[A-Za-z0-9+/=]+).*/\1/' | head -n1 || true)" - fi + # Extract hash from build log with portability + HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" if [ -z "$HASH" ]; then echo "::error::Failed to compute hash for ${SYSTEM}" @@ -86,9 +84,9 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false fetch-depth: 0 ref: ${{ github.ref_name }} diff --git a/nix/hashes.json b/nix/hashes.json index 23ef03d88b..b48e4cb0da 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -2,7 +2,7 @@ "nodeModules": { "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=", "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=", - "aarch64-darwin": "sha256-pWfXomWTDvG8WpWmUCwNXdbSHw6hPlqoT0Q/XuNceMc=", - "x86_64-darwin": "sha256-Dmg4+cUq2r6vZB2ta9tLpNAWqcl11ZCu4ZpieegRFrY=" + "aarch64-darwin": "sha256-jGr2udrVeseioMWpIzpjYFfS1CN8GvNFwo6o92Aa5Oc=", + "x86_64-darwin": "sha256-k5384Uun7tLjKkfJXXPcaZSXQ5jf/tMv21xi5cJU1rM=" } } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx new file mode 100644 index 0000000000..1ca109f232 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx @@ -0,0 +1,34 @@ +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { createResource, createMemo } from "solid-js" +import { useDialog } from "@tui/ui/dialog" +import { useSDK } from "@tui/context/sdk" + +export type DialogSkillProps = { + onSelect: (skill: string) => void +} + +export function DialogSkill(props: DialogSkillProps) { + const dialog = useDialog() + const sdk = useSDK() + + const [skills] = createResource(async () => { + const result = await sdk.client.app.skills() + return result.data ?? [] + }) + + const options = createMemo[]>(() => { + const list = skills() ?? [] + return list.map((skill) => ({ + title: skill.name, + description: skill.description, + value: skill.name, + category: "Skills", + onSelect: () => { + props.onSelect(skill.name) + dialog.clear() + }, + })) + }) + + return +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index bd000e2ab0..5f66dc822a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -345,7 +345,8 @@ export function Autocomplete(props: { const results: AutocompleteOption[] = [...command.slashes()] for (const serverCommand of sync.data.command) { - const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : "" + if (serverCommand.source === "skill") continue + const label = serverCommand.source === "mcp" ? ":mcp" : "" results.push({ display: "/" + serverCommand.name + label, description: serverCommand.description, diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index caa1303229..8576dd5763 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" +import { DialogSkill } from "../dialog-skill" export type PromptProps = { sessionID?: string @@ -315,6 +316,28 @@ export function Prompt(props: PromptProps) { input.cursorOffset = Bun.stringWidth(content) }, }, + { + title: "Skills", + value: "prompt.skills", + category: "Prompt", + slash: { + name: "skills", + }, + onSelect: () => { + dialog.replace(() => ( + { + input.setText(`/${skill} `) + setStore("prompt", { + input: `/${skill} `, + parts: [], + }) + input.gotoBufferEnd() + }} + /> + )) + }, + }, ] }) 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 d36a7d2099..e5b3dc4406 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -43,6 +43,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" +import type { SkillTool } from "@/tool/skill" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -1447,6 +1448,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + + + @@ -1636,7 +1640,9 @@ function Bash(props: ToolProps) { > $ {props.input.command} - {limited()} + + {limited()} + {expanded() ? "Click to collapse" : "Click to expand"} @@ -1795,7 +1801,7 @@ function Task(props: ToolProps) { return ( - + ) { > - {props.input.description} ({props.metadata.summary?.length} toolcalls) + {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls) @@ -1816,22 +1822,17 @@ function Task(props: ToolProps) { - - {keybind.print("session_child_cycle")} - view subagents - + + + {keybind.print("session_child_cycle")} + view subagents + + - - {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task " - {props.input.description}" + + {props.input.subagent_type} Task {props.input.description} @@ -2036,6 +2037,14 @@ function Question(props: ToolProps) { ) } +function Skill(props: ToolProps) { + return ( + + Skill "{props.input.name}" + + ) +} + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index bd1de7d4de..56d8453c93 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -228,7 +228,7 @@ export function DialogSelect(props: DialogSelectProps) { esc - + { batch(() => { diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index dd94cc6097..463a9fb362 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -215,7 +215,7 @@ export namespace Ripgrep { const args = [await filepath(), "--files", "--glob=!.git/*"] if (input.follow) args.push("--follow") - if (input.hidden) args.push("--hidden") + if (input.hidden !== false) args.push("--hidden") if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) if (input.glob) { for (const g of input.glob) { diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts new file mode 100644 index 0000000000..ac46f1131b --- /dev/null +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "../fixture/fixture" +import { Ripgrep } from "../../src/file/ripgrep" + +describe("file.ripgrep", () => { + test("defaults to include hidden", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "visible.txt"), "hello") + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") + }, + }) + + const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path })) + const hasVisible = files.includes("visible.txt") + const hasHidden = files.includes(path.join(".opencode", "thing.json")) + expect(hasVisible).toBe(true) + expect(hasHidden).toBe(true) + }) + + test("hidden false excludes hidden", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "visible.txt"), "hello") + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}") + }, + }) + + const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false })) + const hasVisible = files.includes("visible.txt") + const hasHidden = files.includes(path.join(".opencode", "thing.json")) + expect(hasVisible).toBe(true) + expect(hasHidden).toBe(false) + }) +}) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index d1ade879e2..82b57d13dc 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -569,4 +569,20 @@ flex-direction: column; gap: 12px; } + + [data-slot="session-turn-question-parts"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + } + + [data-slot="session-turn-answered-question-parts"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; + } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 3f176db702..f1c62c0aff 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -4,6 +4,7 @@ import { Message as MessageType, Part as PartType, type PermissionRequest, + type QuestionRequest, TextPart, ToolPart, } from "@opencode-ai/sdk/v2/client" @@ -150,6 +151,8 @@ export function SessionTurn( const emptyAssistant: AssistantMessage[] = [] const emptyPermissions: PermissionRequest[] = [] const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = [] + const emptyQuestions: QuestionRequest[] = [] + const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = [] const emptyDiffs: FileDiff[] = [] const idle = { type: "idle" as const } @@ -281,6 +284,51 @@ export function SessionTurn( return emptyPermissionParts }) + const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions) + const nextQuestion = createMemo(() => questions()[0]) + + const questionParts = createMemo(() => { + if (props.stepsExpanded) return emptyQuestionParts + + const next = nextQuestion() + if (!next || !next.tool) return emptyQuestionParts + + const message = findLast(assistantMessages(), (m) => m.id === next.tool!.messageID) + if (!message) return emptyQuestionParts + + const parts = data.store.part[message.id] ?? emptyParts + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (tool.callID === next.tool?.callID) return [{ part: tool, message }] + } + + return emptyQuestionParts + }) + + const answeredQuestionParts = createMemo(() => { + if (props.stepsExpanded) return emptyQuestionParts + if (questions().length > 0) return emptyQuestionParts + + const result: { part: ToolPart; message: AssistantMessage }[] = [] + + for (const msg of assistantMessages()) { + const parts = data.store.part[msg.id] ?? emptyParts + for (const part of parts) { + if (part?.type !== "tool") continue + const tool = part as ToolPart + if (tool.tool !== "question") continue + // @ts-expect-error metadata may not exist on all tool states + const answers = tool.state?.metadata?.answers + if (answers && answers.length > 0) { + result.push({ part: tool, message: msg }) + } + } + } + + return result + }) + const shellModePart = createMemo(() => { const p = parts() if (p.length === 0) return @@ -640,6 +688,20 @@ export function SessionTurn( + 0}> +
+ + {({ part, message }) => } + +
+
+ 0}> +
+ + {({ part, message }) => } + +
+
{/* Response */}
{!working() && response() ? response() : ""}