dir-tree provides headless annotation utilities for directory trees. It can render annotations into ASCII output, parse edited annotations back from ASCII, normalize provider results, compute diffs, and apply accepted patches.
The package does not call AI providers. Applications own model requests, tokens, quotas, storage, analytics, and notifications.
Annotations are keyed by FileNode.path.
interface TreeAnnotation {
path: string;
comment: string;
source: 'manual' | 'ai';
syncStatus: 'local' | 'synced';
updatedAt: number;
}
type TreeAnnotationMap = Record<string, TreeAnnotation>;
interface TreeAnnotationPatch {
path: string;
comment: string;
source?: 'manual' | 'ai';
syncStatus?: 'local' | 'synced';
updatedAt?: number;
}Patches are intentionally small. They represent a requested comment change for one path. The patch utilities fill default source, syncStatus, and updatedAt values when applying patches.
Use AnnotationProvider as an application boundary. The provider receives a plain payload and returns patches. It can call OpenAI, Ollama, an internal API, a local rules engine, or a mock implementation.
interface AnnotationProvider {
annotate: (
payload: AnnotationRequestPayload,
signal?: AbortSignal
) => Promise<AnnotationProviderResult>;
}Example:
import {
createAnnotationProviderRequest,
createTreeAnnotationPatchesFromProviderResult,
type AnnotationProvider,
} from '@devmc12/dir-tree/annotations';
const provider: AnnotationProvider = {
async annotate(payload, signal) {
const response = await fetch('/api/annotations', {
method: 'POST',
body: JSON.stringify(payload),
signal,
});
return await response.json();
},
};
const request = createAnnotationProviderRequest({
annotations,
language: 'en',
overwrite: false,
prompt: 'Describe each directory in one short phrase',
scope: 'visible',
target: 'directories',
tree,
visibleTree,
});
const result = await provider.annotate(request.payload);
const patches = createTreeAnnotationPatchesFromProviderResult(
result,
request.sourcePaths
);request.payload contains normalized language, trimmed prompt, nodes, and the selected scope, target, and overwrite flags so provider boundaries can stay stateless.
The core package does not know what /api/annotations is. That endpoint belongs to the host application.
Create a diff before applying provider patches so a UI can review added, updated, and skipped entries.
import {
applyTreeAnnotationPatches,
createAnnotationDiffResult,
} from '@devmc12/dir-tree/annotations';
const diff = createAnnotationDiffResult(
annotations,
patches,
request.allowedPaths
);
const nextAnnotations = applyTreeAnnotationPatches(
annotations,
diff.applyPatches
);When a source is refreshed, a host application can reset annotations or retain only annotations whose paths still exist in the new tree.
import { resolveTreeAnnotationsAfterRead } from '@devmc12/dir-tree/annotations';
const nextAnnotations = resolveTreeAnnotationsAfterRead(
nextTree,
annotations,
'matching-paths'
);Retention modes:
reset: drop all annotationsmatching-paths: keep annotations whose paths exist in the new tree
Diff groups:
added: new comments for paths without existing commentsupdated: changed comments for paths with existing commentsskipped: empty comments, unchanged comments, or paths outside the allowed scope
removeAnnotationDiffEntry can remove an individual diff entry before applying accepted patches.
Render annotations beside ASCII tree lines:
import { renderAsciiTreeLines } from '@devmc12/dir-tree/ascii';
import { renderAnnotatedAsciiTree } from '@devmc12/dir-tree/annotations';
const lines = renderAsciiTreeLines(tree);
const text = renderAnnotatedAsciiTree(lines, annotations, {
alignmentMode: 'smart-column',
commentPrefix: '#',
commentColumn: 40,
});Normalize UI or persisted annotation render settings with createAnnotatedAsciiTreeRenderOptionsFromConfig.
import { createAnnotatedAsciiTreeRenderOptionsFromConfig } from '@devmc12/dir-tree/annotations';
const options = createAnnotatedAsciiTreeRenderOptionsFromConfig({
alignmentMode: 'whole-tree',
commentPrefix: '//',
commentPrefixHasSpace: true,
});alignmentMode keeps four stable mode identifiers. Let C be the preferred commentColumn, G be gap, and W be the terminal display width of one rendered tree line.
| Mode | Comment position |
|---|---|
smart-column |
Each annotated line uses max(C, W + G). Long paths fall back independently and do not move other comments. |
whole-tree |
All annotated lines share max(C, longest annotated W + G). |
folder-groups |
Annotated siblings under the same parent share max(C, longest annotated W in that group + G). Each parent group is independent. |
inline |
Each comment follows its path at W + G, without column alignment. |
Only non-synthetic lines that actually have annotations participate in whole-tree and folder-groups width calculations. An unannotated long path therefore does not add whitespace to unrelated comments. Synthetic root lines are never annotated or measured for annotation alignment.
The preferred comment column defaults to 40. The gap defaults to 2 and is normalized to a finite integer of at least 2. Inline layout should set the gap explicitly:
const options = createAnnotatedAsciiTreeRenderOptionsFromConfig({
alignmentMode: 'inline',
gap: 2,
});For backward compatibility, alignmentMode: 'inline' still treats an explicitly supplied commentColumn as the gap when gap is omitted. This legacy fallback is deprecated; new integrations should keep the aligned comment column and inline gap as separate settings. rootCommentOffset continues to shift aligned root targets and does not affect inline layout. In smart-column, a root whose own width plus gap exceeds the shifted preferred column still uses the long-line fallback, matching prior behavior.
Widths are measured as terminal columns. CJK and full-width characters occupy two columns, combining sequences occupy one rendered grapheme width, emoji sequences use their rendered terminal width, and tabs advance to four-column tab stops. This makes exported text deterministic in terminal-style monospace rendering. Browsers and Markdown editors can still show small pixel differences when their monospace font falls back to a different CJK or emoji font; that font-level behavior does not change the terminal column calculation.
Parse edited annotated ASCII back into patches:
import { parseAnnotatedAsciiTree } from '@devmc12/dir-tree/annotations';
const parsed = parseAnnotatedAsciiTree(lines, editedText, {
commentTemplate: '# %comment%',
});Use createEditedAsciiAnnotationDiff when users edit annotated ASCII text and a UI needs a review step before applying changes.
import {
createEditedAsciiAnnotationDiff,
parseAnnotatedAsciiTree,
} from '@devmc12/dir-tree/annotations';
const parsed = parseAnnotatedAsciiTree(lines, editedText, {
commentTemplate: '# %comment%',
});
const diff = createEditedAsciiAnnotationDiff(
parsed,
editedText,
lines,
annotations
);The result separates:
addedupdatedremovedignoredapplyPatchesignoredLineNumbers
Ignored lines are useful when the edited text contains comments that cannot be mapped back to a known tree path.
Keep these responsibilities outside dir-tree core:
- prompt UI
- provider selection UI
- real model requests
- authentication and user tokens
- quota and billing
- toast notifications
- analytics
- storage
- product-specific API routes
Keep these responsibilities inside core:
- provider payload construction
- provider result normalization
- path-scoped patch filtering
- diff computation
- patch application
- annotated ASCII render and parse