Skip to content

Commit 5fcdb31

Browse files
Render AI markdown cleanly in the terminal
Co-authored-by: devmap-agent <238585242+devmap-agent@users.noreply.github.com>
1 parent 652ab9a commit 5fcdb31

10 files changed

Lines changed: 381 additions & 12 deletions

File tree

docs/for-me-personal/DEBUG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,48 @@ test satu pertanyaan.
606606

607607
---
608608

609+
## 12. Jawaban AI Menampilkan Markdown Mentah di Terminal
610+
611+
**Tanggal:** 2026-06-12
612+
613+
**Status:** Selesai.
614+
615+
### Gejala
616+
617+
Jawaban `devmap ask` menampilkan marker seperti `**bold**`, backtick, dan table
618+
pipe secara literal. Tabel lebar terpotong oleh terminal dan sulit dipindai.
619+
620+
### Akar Masalah
621+
622+
Konten AI langsung dikirim ke `output.codeBlock()`. Helper tersebut cocok untuk
623+
source preview, tetapi tidak memahami struktur Markdown yang dihasilkan model.
624+
625+
### Solusi
626+
627+
- Tambahkan pure utility `renderTerminalMarkdown()`.
628+
- Render heading, prose, list, fenced code, dan inline formatting.
629+
- Ubah Markdown table menjadi record vertikal.
630+
- Bungkus text berdasarkan lebar terminal.
631+
- Gunakan renderer hanya untuk jawaban AI `ask` dan interpretation `analyze`.
632+
- Pertahankan `codeBlock()` untuk static source context.
633+
634+
### Verifikasi
635+
636+
- Unit test mencakup heading, inline marker, list, table, wrapping, dan code
637+
fence.
638+
- Integration test memastikan output `ask` dan cached `analyze` tidak
639+
menampilkan marker Markdown mentah.
640+
- Preview manual dengan contoh database menghasilkan blok `users` dan `rooms`
641+
yang terbaca tanpa table pipe.
642+
643+
### Pelajaran
644+
645+
AI response dan source preview adalah dua jenis output berbeda. AI response
646+
memerlukan semantic rendering, sedangkan source code harus dipertahankan
647+
literal.
648+
649+
---
650+
609651
## Checklist Saat Menambahkan Debug Baru
610652

611653
Tambahkan catatan baru ketika:

docs/for-me-personal/PROGRESS.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,23 @@ Terakhir diperbarui: 2026-06-12
1717
- Automated test mencakup warning terminal, snapshot persistence, framework
1818
fallback, dan keberhasilan pembuatan snapshot.
1919

20+
### Terminal Markdown Rendering
21+
22+
- Jawaban AI dari `devmap ask` dan architecture interpretation dari
23+
`devmap analyze` sekarang dirender sebagai output terminal yang terstruktur.
24+
- Heading memakai accent aqua dan separator.
25+
- Marker Markdown inline seperti bold, italic, strikethrough, link, dan
26+
backtick tidak lagi tampil mentah.
27+
- Ordered dan unordered list mempertahankan indentasi yang mudah dipindai.
28+
- Markdown table diubah menjadi record vertikal agar tetap terbaca pada
29+
terminal sempit.
30+
- Prose dibungkus berdasarkan `process.stdout.columns` dengan minimum width
31+
yang aman.
32+
- Fenced code tetap ditampilkan sebagai source block dan static context tidak
33+
diproses sebagai Markdown.
34+
- Automated test mencakup heading, inline formatting, list, table, wrapping,
35+
fenced code, serta integrasi `ask` dan cached `analyze`.
36+
2037
## Update 2026-06-11
2138

2239
### Workflow Agent
@@ -97,7 +114,7 @@ Terakhir diperbarui: 2026-06-12
97114
- Availability selected model ikut diperiksa.
98115
- Snapshot dibedakan menjadi valid, missing, corrupt, dan unsupported schema.
99116
- API key dan raw stack trace tidak pernah ditampilkan.
100-
- Automated test saat ini berjumlah 35 dan seluruhnya lulus.
117+
- Automated test saat ini berjumlah 38 dan seluruhnya lulus.
101118

102119
### Distribusi npm
103120

docs/for-me-personal/TEST.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ Saat ini test mencakup:
102102
Hasil minimum yang diharapkan:
103103

104104
```text
105-
tests 34
106-
pass 34
105+
tests 38
106+
pass 38
107107
fail 0
108108
```
109109

@@ -144,11 +144,42 @@ npx -p node@20 node packages\cli\node_modules\tsx\dist\cli.mjs packages\cli\test
144144
Hasil minimum yang diharapkan untuk keduanya:
145145

146146
```text
147-
tests 34
148-
pass 34
147+
tests 38
148+
pass 38
149149
fail 0
150150
```
151151

152+
## Testing Terminal Markdown Renderer
153+
154+
Jalankan unit dan integration test terkait:
155+
156+
```powershell
157+
pnpm --filter devmap exec tsx --test test/markdown-terminal.test.ts test/ask-command.test.ts test/analyze-ai.test.ts
158+
```
159+
160+
Pastikan:
161+
162+
- heading tidak menampilkan marker `##`;
163+
- bold, italic, dan inline code tidak menampilkan marker Markdown mentah;
164+
- ordered dan unordered list tetap memiliki indentasi;
165+
- table diubah menjadi blok vertikal tanpa karakter pipe;
166+
- prose dibungkus sesuai lebar terminal;
167+
- fenced code tetap terbaca sebagai code block;
168+
- jawaban `ask` dan architecture `analyze` memakai renderer;
169+
- static source context tetap ditampilkan literal.
170+
171+
Manual test dengan AI live:
172+
173+
```powershell
174+
devmap ask "Jelaskan struktur database dalam tabel dan alur utama aplikasi"
175+
```
176+
177+
Hasil yang diharapkan:
178+
179+
- tidak ada `**bold**`, backtick, atau table pipe mentah;
180+
- tabel tetap terbaca pada terminal VS Code yang sempit;
181+
- output tidak melewati lebar terminal secara berlebihan.
182+
152183
## Testing AI Client Tanpa API Call
153184

154185
Automated test AI memakai fake `fetch` dan mock `AiClient`, sehingga tidak

packages/cli/src/commands/analyze.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ async function printOrGenerateInterpretation(
112112
): Promise<void> {
113113
if (snapshot.ai && !options.fresh) {
114114
output.section("Architecture");
115-
output.codeBlock(snapshot.ai.architecture);
115+
output.markdown(snapshot.ai.architecture);
116116
output.note(formatAiMetadata(snapshot.ai.model, snapshot.ai.usage, true));
117117
return;
118118
}
@@ -154,7 +154,7 @@ async function printOrGenerateInterpretation(
154154

155155
await saveSnapshot(projectRoot, updatedSnapshot);
156156
output.section("Architecture");
157-
output.codeBlock(interpretation.content);
157+
output.markdown(interpretation.content);
158158
output.note(formatAiMetadata(
159159
interpretation.model,
160160
interpretation.usage,

packages/cli/src/commands/ask.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export async function askCommand(
8787
});
8888

8989
output.section("Answer");
90-
output.codeBlock(answer.content);
90+
output.markdown(answer.content);
9191
output.note(formatUsage(answer.model, answer.usage));
9292
} catch (error) {
9393
if (!(error instanceof DevmapError)) {
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
export type MarkdownRenderOptions = {
2+
width?: number;
3+
colors?: boolean;
4+
};
5+
6+
const ANSI = {
7+
aqua: "\x1b[38;2;46;230;214m",
8+
bold: "\x1b[1m",
9+
gray: "\x1b[90m",
10+
reset: "\x1b[0m"
11+
};
12+
13+
export function renderTerminalMarkdown(
14+
markdown: string,
15+
options: MarkdownRenderOptions = {}
16+
): string {
17+
const width = Math.max(32, options.width ?? 80);
18+
const colors = options.colors ?? true;
19+
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
20+
const rendered: string[] = [];
21+
let index = 0;
22+
let inCodeBlock = false;
23+
24+
while (index < lines.length) {
25+
const line = lines[index];
26+
27+
if (/^\s*```/.test(line)) {
28+
inCodeBlock = !inCodeBlock;
29+
index += 1;
30+
continue;
31+
}
32+
33+
if (inCodeBlock) {
34+
rendered.push(style(` ${line}`, "gray", colors));
35+
index += 1;
36+
continue;
37+
}
38+
39+
if (isTableStart(lines, index)) {
40+
const tableEnd = findTableEnd(lines, index);
41+
rendered.push(...renderTable(lines.slice(index, tableEnd), width, colors));
42+
index = tableEnd;
43+
continue;
44+
}
45+
46+
const heading = line.match(/^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$/);
47+
if (heading) {
48+
const title = cleanInline(heading[1]);
49+
rendered.push(style(title, "heading", colors));
50+
rendered.push(style("-".repeat(Math.min(width, Math.max(12, title.length))), "gray", colors));
51+
index += 1;
52+
continue;
53+
}
54+
55+
const listItem = line.match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/);
56+
if (listItem) {
57+
const marker = /^\d/.test(listItem[2]) ? listItem[2] : "-";
58+
const indent = listItem[1].length + (marker === "-" ? 0 : 0);
59+
rendered.push(...wrapWithPrefix(
60+
cleanInline(listItem[3]),
61+
`${" ".repeat(indent)}${marker} `,
62+
width
63+
));
64+
index += 1;
65+
continue;
66+
}
67+
68+
if (line.trim() === "") {
69+
if (rendered.at(-1) !== "") {
70+
rendered.push("");
71+
}
72+
index += 1;
73+
continue;
74+
}
75+
76+
rendered.push(...wrapText(cleanInline(line.trim()), width));
77+
index += 1;
78+
}
79+
80+
return trimBlankLines(rendered).join("\n");
81+
}
82+
83+
function isTableStart(lines: string[], index: number): boolean {
84+
return (
85+
index + 1 < lines.length
86+
&& lines[index].includes("|")
87+
&& /^\s*\|?\s*:?-{3,}/.test(lines[index + 1])
88+
);
89+
}
90+
91+
function findTableEnd(lines: string[], start: number): number {
92+
let index = start + 2;
93+
while (index < lines.length && lines[index].includes("|") && lines[index].trim() !== "") {
94+
index += 1;
95+
}
96+
return index;
97+
}
98+
99+
function renderTable(
100+
tableLines: string[],
101+
width: number,
102+
colors: boolean
103+
): string[] {
104+
const headers = parseTableRow(tableLines[0]).map(cleanInline);
105+
const rows = tableLines.slice(2).map((line) => parseTableRow(line).map(cleanInline));
106+
const labelWidth = Math.min(
107+
18,
108+
Math.max(8, ...headers.slice(1).map((header) => header.length))
109+
);
110+
const rendered: string[] = [];
111+
112+
for (const row of rows) {
113+
const title = row[0] || "Item";
114+
rendered.push(style(title, "heading", colors));
115+
116+
for (let index = 1; index < headers.length; index += 1) {
117+
const label = headers[index] || `Column ${index + 1}`;
118+
const value = row[index] || "-";
119+
rendered.push(...wrapWithPrefix(value, ` ${label.padEnd(labelWidth)} `, width));
120+
}
121+
122+
rendered.push("");
123+
}
124+
125+
return trimBlankLines(rendered);
126+
}
127+
128+
function parseTableRow(line: string): string[] {
129+
return line
130+
.trim()
131+
.replace(/^\|/, "")
132+
.replace(/\|$/, "")
133+
.split("|")
134+
.map((cell) => cell.trim());
135+
}
136+
137+
function cleanInline(value: string): string {
138+
return value
139+
.replace(/!\[([^\]]*)]\([^)]*\)/g, "$1")
140+
.replace(/\[([^\]]+)]\(([^)]+)\)/g, "$1 ($2)")
141+
.replace(/(\*\*|__)(.*?)\1/g, "$2")
142+
.replace(/\*([^*]+)\*/g, "$1")
143+
.replace(/~~(.*?)~~/g, "$1")
144+
.replace(/`([^`]+)`/g, "$1")
145+
.replace(/\\([\\`*_[\]{}()#+\-.!|>])/g, "$1");
146+
}
147+
148+
function wrapText(value: string, width: number): string[] {
149+
return wrapWithPrefix(value, "", width);
150+
}
151+
152+
function wrapWithPrefix(value: string, prefix: string, width: number): string[] {
153+
const continuation = " ".repeat(prefix.length);
154+
const available = Math.max(12, width - prefix.length);
155+
const words = value.split(/\s+/).filter(Boolean);
156+
const lines: string[] = [];
157+
let current = "";
158+
159+
for (const word of words) {
160+
if (!current) {
161+
current = word;
162+
continue;
163+
}
164+
165+
if (`${current} ${word}`.length <= available) {
166+
current = `${current} ${word}`;
167+
continue;
168+
}
169+
170+
lines.push(`${lines.length === 0 ? prefix : continuation}${current}`);
171+
current = word;
172+
}
173+
174+
if (current || lines.length === 0) {
175+
lines.push(`${lines.length === 0 ? prefix : continuation}${current}`);
176+
}
177+
178+
return lines;
179+
}
180+
181+
function style(
182+
value: string,
183+
tone: "heading" | "gray",
184+
colors: boolean
185+
): string {
186+
if (!colors) {
187+
return value;
188+
}
189+
190+
if (tone === "heading") {
191+
return `${ANSI.bold}${ANSI.aqua}${value}${ANSI.reset}`;
192+
}
193+
194+
return `${ANSI.gray}${value}${ANSI.reset}`;
195+
}
196+
197+
function trimBlankLines(lines: string[]): string[] {
198+
let start = 0;
199+
let end = lines.length;
200+
201+
while (start < end && lines[start] === "") {
202+
start += 1;
203+
}
204+
205+
while (end > start && lines[end - 1] === "") {
206+
end -= 1;
207+
}
208+
209+
return lines.slice(start, end);
210+
}

packages/cli/src/utils/output.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { renderTerminalMarkdown } from "./markdownTerminal.js";
2+
13
export const theme = {
24
aqua: "\x1b[38;2;46;230;214m",
35
gray: "\x1b[90m",
@@ -50,5 +52,12 @@ export const output = {
5052

5153
codeBlock(content: string): void {
5254
console.log(color(content, "gray"));
55+
},
56+
57+
markdown(content: string): void {
58+
console.log(renderTerminalMarkdown(content, {
59+
width: process.stdout.columns ?? 80,
60+
colors: true
61+
}));
5362
}
5463
};

0 commit comments

Comments
 (0)