feat(coding-agent): recognize #<number> as a GitHub issue/PR reference

Typing #<number> (e.g. #3164) in the prompt now offers PR and Issue autocomplete candidates; accepting one rewrites the token to the pr:///issue:// internal URL (+ trailing space, matching the @/internal-url convention). The existing read tool -> InternalUrlRouter -> gh pipeline resolves it from the cwd's git remote, so no new resolution code is needed.

Bare # and #<text> keep the existing prompt-action menu (additive, no regression). #<number> requires a positive integer, so #0 / leading zeros do not offer candidates.

Closes #3218
This commit is contained in:
oldschoola
2026-06-21 18:04:42 -07:00
parent 0a1323ae80
commit c7ec6d066d
6 changed files with 160 additions and 2 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Typing `#<number>` (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
@@ -0,0 +1,55 @@
/**
* Autocomplete for GitHub issue/PR references typed as `#<number>` (e.g. `#3164`).
*
* Mirrors the `@` file-reference and `scheme://` internal-url conventions: the
* `#<number>` 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 `#<digits>`
* token keeps falling through to the existing prompt-action menu.
*/
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
/** Candidates offered for a `#<number>` 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 `#<number>` 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 `#<number>` 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 `#<number>` 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 };
}
@@ -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 {
@@ -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 |",
"| `#<number>` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |",
"| `#` / `#<text>` | Prompt actions (copy / undo / move cursor) |",
"| `/` | Slash commands |",
"| `!` | Run bash command |",
"| `!!` | Run bash command (excluded from context) |",
@@ -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("| `#<number>` | GitHub issue/PR reference");
expect(markdown).toContain("| `#` / `#<text>` | Prompt actions");
for (const line of lines) {
if (line.length === 0) continue;
expect(line.startsWith(" ")).toBe(false);
@@ -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 #<digits> 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 #<number>", () => {
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 #<text> 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);
});
});