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
13 changes: 12 additions & 1 deletion UserGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@

## Jay - X

## Cathy - X
## Cathy - Repository Learning Map (`/group`)

Open an OpenCode session in a repository, select **Learn** mode, and enter `/group`. The map lists functionality groups. Select one to browse subgroups and files; use **Next page** or **Previous page** on long file lists. Choose **EXPLAIN THIS FUNCTIONALITY** or **EXPLAIN THIS SUBGROUP** to ask for a focused, read-only guide. The answer should cite files, explain relationships to other areas, suggest a reading order, and ask a check-your-understanding question.

To see session-aware ordering, ask Learn to read a file, then run `/group` in the same session. Groups containing files read in this session are prioritized. If model-generated names are unavailable, directory-based names remain. The file inventory is capped at 10,000 paths, so very large repositories can show a partial map. Naming and explanations use model tokens.

**End-to-end test (manual):** In Learn mode, run `/group` in a repository containing `src/`, `test/`, or `packages/` files. Open a group and subgroup, page through files, and request both explanations. The answers should name real files, describe evidence-backed relationships, give a reading order, and ask a learning question. Ask Learn to read a file, reopen `/group`, and confirm its group moves toward the top. In an empty repository, `/group` should show an informational message without crashing. Learn should remain selected throughout. This manual test covers the complete TUI and model workflow; the automated tests below do not launch the full application.

**Unit tests (automated):** [`packages/tui/test/util/repository-functionality.test.ts`](packages/tui/test/util/repository-functionality.test.ts) covers repository layouts, empty inventories, 30-group and 20-subgroup limits, overflow preservation, priority, prompt bounds, and naming fallback. [`packages/tui/test/util/referenced-file.test.ts`](packages/tui/test/util/referenced-file.test.ts) covers completed reads, excluded results, absolute and relative paths, de-duplication, and `/group` registration.

**Integration test (automated):** [`packages/tui/test/integration/repository-map.test.ts`](packages/tui/test/integration/repository-map.test.ts) passes completed read records through path collection, repository grouping, and both naming and explanation prompts. It verifies that a real session path remains prioritized and appears as evidence in the resulting guides. From `packages/tui`, run `bun test test/util/repository-functionality.test.ts test/util/referenced-file.test.ts test/integration/repository-map.test.ts` and `bun typecheck`.


## Janna - X

Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export function Session() {
run: async () => {
const referencedFiles = collectReferencedFiles(
messages().flatMap((message) => sync.data.part[message.id] ?? []),
project.instance.directory(),
)
dialog.replace(() => <DialogRepositoryMapLoading />)
const result = await sdk.client.find.files({
Expand All @@ -503,6 +504,7 @@ export function Session() {
const naming = await sdk.client.session.prompt({
sessionID: namingSession.data.id,
workspace: project.workspace.current(),
agent: "learn",
parts: [{ type: "text", text: buildGroupNamingPrompt(groups) }],
})
const text = naming.data?.parts
Expand Down
6 changes: 4 additions & 2 deletions packages/tui/src/util/referenced-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ export const referencedFileCommand = {
},
} as const

export function collectReferencedFiles(parts: readonly SessionPart[]) {
export function collectReferencedFiles(parts: readonly SessionPart[], directory = "") {
const seen = new Set<string>()
const root = directory.replaceAll("\\", "/").replace(/\/$/, "")

return parts.flatMap((part) => {
const filePath = referencedFilePath(part)
if (!filePath) return []

const normalized = filePath.replaceAll("\\", "/")
const path = filePath.replaceAll("\\", "/")
const normalized = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
if (seen.has(normalized)) return []
seen.add(normalized)
return [normalized]
Expand Down
13 changes: 11 additions & 2 deletions packages/tui/src/util/repository-functionality.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,24 @@ export function analyzeRepositoryGroups(input: readonly string[], referencedFile
root: ".",
files: rest.flatMap((item) => item.files),
referencedFiles: rest.flatMap((item) => item.referencedFiles),
subgroups: rest.flatMap((item) => item.subgroups),
subgroups: limit(rest.flatMap((item) => item.subgroups), 20, (subgroups) => ({
id: "other-repository-groups/other",
title: "Other areas",
root: ".",
files: subgroups.flatMap((item) => item.files),
referencedFiles: subgroups.flatMap((item) => item.referencedFiles),
})),
}))
}

export function buildGroupNamingPrompt(groups: readonly FunctionalGroup[]) {
return [
"Give each repository group a concise, beginner-friendly functionality name.",
"Return only a JSON object mapping each exact group id to a name of at most four words.",
...groups.flatMap((group) => [`Group: ${group.id}`, ...group.files.slice(0, 5).map((file) => `- ${file}`)]),
...groups.flatMap((group) => [
`Group: ${group.id}`,
...[...new Set([...group.referencedFiles, ...group.files])].slice(0, 5).map((file) => `- ${file}`),
]),
].join("\n")
}

Expand Down
39 changes: 39 additions & 0 deletions packages/tui/test/integration/repository-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { expect, test } from "bun:test"
import { collectReferencedFiles } from "../../src/util/referenced-file"
import { analyzeRepositoryGroups, buildFunctionalGroupPrompt, buildGroupNamingPrompt } from "../../src/util/repository-functionality"

test("integration: completed session reads prioritize map groups and ground their guides", () => {
const referenced = collectReferencedFiles(
[
{ type: "tool", tool: "read", state: { status: "completed", input: { filePath: "/repo/apps/web/src/home.tsx" } } },
{ type: "tool", tool: "read", state: { status: "error", input: { filePath: "/repo/packages/api/src/user.ts" } } },
],
"/repo",
)
const groups = analyzeRepositoryGroups(
["packages/api/src/user.ts", "apps/web/src/home.tsx", "apps/web/test/home.test.ts"],
referenced,
)
const web = groups[0]

expect(referenced).toEqual(["apps/web/src/home.tsx"])
expect(web.id).toBe("apps/web")
expect(web.referencedFiles).toEqual(referenced)
expect(buildGroupNamingPrompt(groups)).toContain("- apps/web/src/home.tsx")
expect(buildFunctionalGroupPrompt(web, groups)).toContain("- apps/web/src/home.tsx")
expect(buildFunctionalGroupPrompt(web.subgroups[0], groups)).toContain("- apps/web/src/home.tsx")
})

test("integration: repeated session reads count once in the map and guide", () => {
const reads = [
{ type: "tool", tool: "read", state: { status: "completed", input: { filePath: "/repo/apps/web/src/home.tsx" } } },
{ type: "tool", tool: "read", state: { status: "completed", input: { filePath: "apps/web/src/home.tsx" } } },
]
const referenced = collectReferencedFiles(reads, "/repo")
const [web] = analyzeRepositoryGroups(["apps/web/src/home.tsx", "apps/web/src/about.tsx"], referenced)
const prompt = buildFunctionalGroupPrompt(web, [web])

expect(referenced).toEqual(["apps/web/src/home.tsx"])
expect(web.referencedFiles).toEqual(["apps/web/src/home.tsx"])
expect(prompt.match(/- apps\/web\/src\/home\.tsx/g)).toHaveLength(1)
})
55 changes: 55 additions & 0 deletions packages/tui/test/util/referenced-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,61 @@ describe("referenced files", () => {
).toEqual(["src/index.ts", "test/index.test.ts"])
})

test("equivalence partitioning: metadata file paths override input paths", () => {
expect(
collectReferencedFiles([
{
type: "tool",
tool: "read",
state: {
status: "completed",
input: { filePath: "outdated.ts" },
metadata: { display: { type: "file", path: "src/current.ts" } },
},
},
{
type: "tool",
tool: "read",
state: {
status: "completed",
input: { filePath: "directory.ts" },
metadata: { display: { type: "directory", path: "src" } },
},
},
completedRead("src/fallback.ts"),
]),
).toEqual(["src/current.ts", "src/fallback.ts"])
})

test("equivalence partitioning: ignores unreadable or unfinished tool results", () => {
expect(
collectReferencedFiles([
{ type: "tool", tool: "read", state: { status: "completed", input: { filePath: " " } } },
{ type: "tool", tool: "read", state: { status: "completed", input: { filePath: 42 } } },
{ type: "tool", tool: "read", state: { status: "error", input: { filePath: "failed.ts" } } },
{ type: "tool", tool: "write", state: { status: "completed", input: { filePath: "written.ts" } } },
{ type: "text" },
]),
).toEqual([])
})

test("matches absolute read paths to relative repository files", () => {
expect(
collectReferencedFiles(
[
completedRead("/home/student/project/src/index.ts"),
completedRead("src/index.ts"),
completedRead("/home/student/project-copy/src/other.ts"),
],
"/home/student/project",
),
).toEqual(["src/index.ts", "/home/student/project-copy/src/other.ts"])

expect(
collectReferencedFiles([completedRead("C:\\repo\\src\\index.ts")], "C:\\repo"),
).toEqual(["src/index.ts"])
})

test("registers /group", () => {
expect(referencedFileCommand.slash.name).toBe("group")
})
Expand Down
136 changes: 136 additions & 0 deletions packages/tui/test/util/repository-functionality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ describe("repository areas", () => {
expect(prompt).not.toContain("src/session/file-8.ts")
})

test("includes session-referenced files in the naming sample", () => {
const files = Array.from({ length: 12 }, (_, index) => `src/session/file-${index}.ts`)
const referenced = files.at(-1)!
const [group] = analyzeRepositoryGroups(files, [referenced])
const prompt = buildGroupNamingPrompt([group])

expect(prompt).toContain(`- ${referenced}`)
expect(prompt.split("\n- ")).toHaveLength(6)
})

test("limits the map and applies model-generated names", () => {
const groups = analyzeRepositoryGroups(
Array.from({ length: 35 }, (_, index) => `packages/area-${index}/src/index.ts`),
Expand All @@ -61,4 +71,130 @@ describe("repository areas", () => {
expect(renamed[0].title).toBe("Session History")
expect(buildGroupNamingPrompt(groups)).toContain("Group: packages/area-34")
})

test("caps subgroups in the overflow group without losing files", () => {
const files = Array.from({ length: 55 }, (_, index) => `packages/area-${index}/src/index.ts`)
const groups = analyzeRepositoryGroups(files, files)
const overflow = groups.at(-1)!

expect(groups).toHaveLength(30)
expect(overflow.title).toBe("Other repository groups")
expect(overflow.subgroups).toHaveLength(20)
expect(overflow.subgroups.flatMap((subgroup) => subgroup.files).sort()).toEqual([...overflow.files].sort())
expect(overflow.subgroups.flatMap((subgroup) => subgroup.referencedFiles).sort()).toEqual(
[...overflow.referencedFiles].sort(),
)
})

test("handles empty repositories and ignores generated-only inventories", () => {
expect(analyzeRepositoryGroups([])).toEqual([])
expect(analyzeRepositoryGroups(["node_modules/pkg/index.js", "dist/app.js", ".git/config"])).toEqual([])
})

test("keeps root files and unfamiliar layouts without losing paths", () => {
const files = [
"README.md",
"mystery/component.custom",
"services/api/lib/start.ts",
"modules/parser/spec/check.txt",
]
const groups = analyzeRepositoryGroups(files)

expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort())
expect(groups.find((group) => group.id === ".")?.files).toEqual(["README.md"])
expect(groups.map((group) => group.id)).toEqual(
expect.arrayContaining(["mystery", "services/api", "modules/parser"]),
)
})

test.each([19, 20, 21, 25])("boundary-value analysis: preserves files with %i subgroups", (count) => {
const files = Array.from({ length: count }, (_, index) => `packages/api/src/feature-${index}/index.ts`)
const [group] = analyzeRepositoryGroups(files, files)

expect(group.subgroups).toHaveLength(Math.min(count, 20))
expect(group.subgroups.flatMap((subgroup) => subgroup.files).sort()).toEqual([...files].sort())
expect(group.subgroups.flatMap((subgroup) => subgroup.referencedFiles).sort()).toEqual([...files].sort())
if (count > 20) {
expect(group.subgroups.at(-1)?.title).toBe("Other areas")
expect(group.subgroups.at(-1)?.files).toHaveLength(count - 19)
}
})

test.each([29, 30, 31, 35])("boundary-value analysis: preserves files with %i groups", (count) => {
const files = Array.from({ length: count }, (_, index) => `packages/area-${index}/src/index.ts`)
const groups = analyzeRepositoryGroups(files, files)

expect(groups).toHaveLength(Math.min(count, 30))
expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort())
expect(groups.flatMap((group) => group.referencedFiles).sort()).toEqual([...files].sort())
expect(groups.flatMap((group) => group.subgroups.flatMap((subgroup) => subgroup.files)).sort()).toEqual(
[...files].sort(),
)
if (count > 30) {
expect(groups.at(-1)?.title).toBe("Other repository groups")
expect(groups.at(-1)?.files).toHaveLength(count - 29)
}
})

test.each(["", "No names available", '{"src":', '{"src": "Source",}'])(
"retains structural names for malformed naming response %j",
(response) => {
const groups = analyzeRepositoryGroups(["src/main.ts", "docs/guide.md"])
expect(applyGroupNames(groups, response)).toEqual(groups)
},
)

test("metamorphic testing: duplicate and reordered path formats preserve the map", () => {
const files = ["packages/api/src/login.ts", "packages/api/test/login.test.ts", "apps/web/src/home.tsx"]
const expected = analyzeRepositoryGroups(files, ["apps/web/src/home.tsx"])
const changed = analyzeRepositoryGroups(
["./apps/web/src/home.tsx", "packages\\api\\test\\login.test.ts", ...files.toReversed(), files[0]],
["apps\\web\\src\\home.tsx"],
)

expect(changed).toEqual(expected)
})

test("generated-input property: every accepted file belongs to one group and subgroup", () => {
for (const count of [1, 2, 17, 18, 19, 30, 31, 75]) {
const files = Array.from({ length: count }, (_, index) =>
index % 2 ? `apps/web/test/case-${index}.test.ts` : `packages/api/src/feature-${index}/index.ts`,
)
const groups = analyzeRepositoryGroups(
files.flatMap((file, index) => (index % 2 ? [file, file] : [`./${file}`, file])),
[files[0], files.at(-1)!],
)
const references = [...new Set([files[0], files.at(-1)!])].sort()

expect(groups.flatMap((group) => group.files).sort()).toEqual([...files].sort())
expect(groups.flatMap((group) => group.subgroups.flatMap((subgroup) => subgroup.files)).sort()).toEqual(
[...files].sort(),
)
expect(groups.flatMap((group) => group.referencedFiles).sort()).toEqual(references)
}
})

test("pairwise testing: a late referenced file survives prompt length limits", () => {
const files = Array.from({ length: 35 }, (_, index) => `src/session/file-${index}.ts`)
const referenced = files.at(-1)!
const [group] = analyzeRepositoryGroups(files, [referenced])
const prompt = buildFunctionalGroupPrompt(group, [group])
const listed = prompt.split("Representative files:\n")[1].split("\n- (", 1)[0].split("\n")

expect(listed).toHaveLength(15)
expect(listed[0]).toBe(`- ${referenced}`)
expect(prompt).toContain(`(${files.length - 15} additional files omitted)`)
})

test("retains names for missing and non-string values without changing the original map", () => {
const groups = analyzeRepositoryGroups(["src/main.ts", "docs/guide.md", "tests/main.test.ts"])
const original = structuredClone(groups)
const renamed = applyGroupNames(groups, '{"src": "Application", "docs": 42, "unrelated": "Unused"}')

expect(renamed.find((group) => group.id === "src")?.title).toBe("Application")
expect(renamed.find((group) => group.id === "docs")).toEqual(groups.find((group) => group.id === "docs"))
expect(renamed.find((group) => group.id === "tests")).toEqual(groups.find((group) => group.id === "tests"))
expect(renamed).toHaveLength(groups.length)
expect(groups).toEqual(original)
})
})
Loading