diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 13b3ae74d..3be8059ea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Typing `#` (e.g. `#3164`) in the prompt now offers PR and Issue autocomplete candidates that rewrite to the `pr://`/`issue://` internal URL, resolved from the current repo's git remote via the existing `read` tool → InternalUrlRouter → `gh` pipeline ([#3218](https://github.com/can1357/oh-my-pi/issues/3218)) + ## [16.1.12] - 2026-06-21 ### Changed diff --git a/packages/coding-agent/src/modes/github-ref-autocomplete.ts b/packages/coding-agent/src/modes/github-ref-autocomplete.ts new file mode 100644 index 000000000..f60d9a5d5 --- /dev/null +++ b/packages/coding-agent/src/modes/github-ref-autocomplete.ts @@ -0,0 +1,55 @@ +/** + * Autocomplete for GitHub issue/PR references typed as `#` (e.g. `#3164`). + * + * Mirrors the `@` file-reference and `scheme://` internal-url conventions: the + * `#` token is rewritten to an internal URL (`pr://3164` or + * `issue://3164`) plus a trailing space, and the existing tool-mediated pipeline + * (the `read` tool → InternalUrlRouter → `gh`) resolves it from the session + * cwd's git remote. + * + * No network at suggestion time — candidates are generated locally. GitHub + * shares the issue/PR number space and there is no cheap way to tell which a + * given number is while typing, so both a PR and an Issue candidate are offered + * (PR first — the more common reference in a coding context) and the user + * disambiguates by accepting the right one. Anything that is not a pure `#` + * token keeps falling through to the existing prompt-action menu. + */ +import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; + +/** Candidates offered for a `#` token, in display order. */ +const GITHUB_REF_KINDS = [ + { scheme: "pr", label: "PR", description: "GitHub pull request" }, + { scheme: "issue", label: "Issue", description: "GitHub issue" }, +] as const; + +/** + * Detect a `#` token ending at the cursor. Only a `#` followed by a + * positive integer (no leading zeros) qualifies, so `#3164` matches but `#`, + * `#0`, `#0123`, `#copy`, and `#3164abc` do not. + */ +export function getGithubRefPrefix(textBeforeCursor: string): string | null { + const hashIndex = textBeforeCursor.lastIndexOf("#"); + if (hashIndex === -1) return null; + const token = textBeforeCursor.slice(hashIndex + 1); + if (!/^[1-9]\d*$/.test(token)) return null; + return `#${token}`; +} + +/** + * Suggestions for a `#` token: a PR candidate and an Issue candidate, + * each rewriting to the corresponding internal URL on accept. Returns `null` + * when the text before the cursor is not a `#` token. + */ +export function getGithubRefSuggestions( + textBeforeCursor: string, +): { items: AutocompleteItem[]; prefix: string } | null { + const prefix = getGithubRefPrefix(textBeforeCursor); + if (!prefix) return null; + const number = prefix.slice(1); + const items: AutocompleteItem[] = GITHUB_REF_KINDS.map(kind => ({ + value: `${kind.scheme}://${number}`, + label: `${kind.label} #${number}`, + description: kind.description, + })); + return { items, prefix }; +} diff --git a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts index 1ec0dcc9c..2f9507fa8 100644 --- a/packages/coding-agent/src/modes/prompt-action-autocomplete.ts +++ b/packages/coding-agent/src/modes/prompt-action-autocomplete.ts @@ -8,6 +8,7 @@ import { import { formatKeyHints, type KeybindingsManager } from "../config/keybindings"; import { isSettingsInitialized, settings } from "../config/settings"; import { applyEmojiCompletion, getEmojiSuggestions, isEmojiPrefix, tryEmojiInlineReplace } from "./emoji-autocomplete"; +import { getGithubRefPrefix, getGithubRefSuggestions } from "./github-ref-autocomplete"; import { applyInternalUrlCompletion, getInternalUrlSuggestions, @@ -109,6 +110,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { ): Promise<{ items: AutocompleteItem[]; prefix: string } | null> { const currentLine = lines[cursorLine] || ""; const textBeforeCursor = currentLine.slice(0, cursorCol); + const githubRefSuggestions = getGithubRefSuggestions(textBeforeCursor); + if (githubRefSuggestions) return githubRefSuggestions; const promptActionPrefix = getPromptActionPrefix(textBeforeCursor); if (promptActionPrefix) { const query = promptActionPrefix.slice(1).toLowerCase(); @@ -156,6 +159,9 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider { cursorCol: number; onApplied?: () => void; } { + if (getGithubRefPrefix(prefix)) { + return applyInternalUrlCompletion(lines, cursorLine, cursorCol, item, prefix); + } if (prefix.startsWith("#") && isPromptActionItem(item)) { if (item.actionId === "undo") { return { diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index f523732a1..419e91792 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -52,7 +52,8 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`, "| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |", `| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`, - "| `#` | Open prompt actions |", + "| `#` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |", + "| `#` / `#` | Prompt actions (copy / undo / move cursor) |", "| `/` | Slash commands |", "| `!` | Run bash command |", "| `!!` | Run bash command (excluded from context) |", diff --git a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts index 382d2ec56..ce05a9fb5 100644 --- a/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts +++ b/packages/coding-agent/test/modes/controllers/command-controller-hotkeys.test.ts @@ -41,7 +41,8 @@ describe("buildHotkeysMarkdown", () => { expect(markdown).toContain("| `Ctrl+L` | Reset terminal display |"); expect(markdown).toContain("| `Alt+R` | Retry last failed assistant turn |"); expect(markdown).toContain("| `Alt+Shift+P` | Toggle plan mode |"); - expect(markdown).toContain("| `#` | Open prompt actions |"); + expect(markdown).toContain("| `#` | GitHub issue/PR reference"); + expect(markdown).toContain("| `#` / `#` | Prompt actions"); for (const line of lines) { if (line.length === 0) continue; expect(line.startsWith(" ")).toBe(false); diff --git a/packages/coding-agent/test/modes/github-ref-autocomplete.test.ts b/packages/coding-agent/test/modes/github-ref-autocomplete.test.ts new file mode 100644 index 000000000..2d3289594 --- /dev/null +++ b/packages/coding-agent/test/modes/github-ref-autocomplete.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; +import { KeybindingsManager as AppKeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings"; +import { getGithubRefPrefix, getGithubRefSuggestions } from "@oh-my-pi/pi-coding-agent/modes/github-ref-autocomplete"; +import { createPromptActionAutocompleteProvider } from "@oh-my-pi/pi-coding-agent/modes/prompt-action-autocomplete"; + +function makeProvider() { + return createPromptActionAutocompleteProvider({ + commands: [], + basePath: "/tmp", + keybindings: AppKeybindingsManager.inMemory({}), + copyCurrentLine: () => {}, + copyPrompt: () => {}, + undo: () => {}, + moveCursorToMessageEnd: () => {}, + moveCursorToMessageStart: () => {}, + moveCursorToLineStart: () => {}, + moveCursorToLineEnd: () => {}, + }); +} + +describe("github-ref autocomplete — prefix detection", () => { + it("matches the last # token ending at the cursor", () => { + expect(getGithubRefPrefix("#3164")).toBe("#3164"); + expect(getGithubRefPrefix("look at #3164")).toBe("#3164"); + expect(getGithubRefPrefix("see #1 and #3164")).toBe("#3164"); + }); + + it("does not match bare #, text, or mixed tokens", () => { + expect(getGithubRefPrefix("#")).toBeNull(); + expect(getGithubRefPrefix("#copy")).toBeNull(); + expect(getGithubRefPrefix("#3164abc")).toBeNull(); + expect(getGithubRefPrefix("#3a")).toBeNull(); + // zero / leading zeros are not valid GitHub numbers + expect(getGithubRefPrefix("#0")).toBeNull(); + expect(getGithubRefPrefix("#00")).toBeNull(); + expect(getGithubRefPrefix("#0123")).toBeNull(); + // a space after the digits closes the token + expect(getGithubRefPrefix("#3164 ")).toBeNull(); + expect(getGithubRefPrefix("no hash here")).toBeNull(); + }); +}); + +describe("github-ref autocomplete — suggestions", () => { + it("offers a PR and an Issue candidate for #", () => { + const result = getGithubRefSuggestions("#3164"); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe("#3164"); + expect(result!.items).toEqual([ + { value: "pr://3164", label: "PR #3164", description: "GitHub pull request" }, + { value: "issue://3164", label: "Issue #3164", description: "GitHub issue" }, + ]); + }); + + it("returns null for non-numeric tokens", () => { + expect(getGithubRefSuggestions("#copy")).toBeNull(); + expect(getGithubRefSuggestions("#")).toBeNull(); + expect(getGithubRefSuggestions("#3164abc")).toBeNull(); + expect(getGithubRefSuggestions("#0")).toBeNull(); + }); +}); + +describe("github-ref autocomplete — provider integration", () => { + it("yields the ref candidates and rewrites the token to the chosen internal URL", async () => { + const provider = makeProvider(); + const suggestions = await provider.getSuggestions(["review #3164"], 0, 12); + expect(suggestions).not.toBeNull(); + expect(suggestions!.prefix).toBe("#3164"); + expect(suggestions!.items.map(item => item.value)).toEqual(["pr://3164", "issue://3164"]); + + const pr = suggestions!.items[0]!; + const issue = suggestions!.items[1]!; + + const prResult = provider.applyCompletion(["review #3164"], 0, 12, pr, suggestions!.prefix); + expect(prResult.lines).toEqual(["review pr://3164 "]); + expect(prResult.cursorCol).toBe("review pr://3164 ".length); + + const issueResult = provider.applyCompletion(["review #3164"], 0, 12, issue, suggestions!.prefix); + expect(issueResult.lines).toEqual(["review issue://3164 "]); + }); + + it("leaves # and bare # to the prompt-action menu (no github-ref candidates)", async () => { + const provider = makeProvider(); + const isRef = (value: string) => value.startsWith("pr://") || value.startsWith("issue://"); + + const textSuggestions = await provider.getSuggestions(["#copy"], 0, 5); + expect(textSuggestions?.items.every(item => !isRef(item.value))).toBe(true); + + const bareSuggestions = await provider.getSuggestions(["#"], 0, 1); + expect(bareSuggestions?.items.every(item => !isRef(item.value))).toBe(true); + }); +});