Skip to content

Commit 00e1069

Browse files
Claudeclaude
authored andcommitted
feat: implement CLI UX design system — replace emoji with text indicators and branded output
Replace all emoji in MCP tool display output with deterministic text indicators ([!!], [!], [~], [-]) and add branded product line headers (gitmem -- <tool>) with ANSI color support. Respects NO_COLOR env var. - Rewrite display-protocol.ts as central design system module - Add ANSI color constants mapped to gitmem.ai brand palette - Replace emoji severity indicators across all 10 tool files - Add productLine(), boldText(), dimText() formatting utilities - Update all test expectations to match new text indicators - Add docs/cli-ux-guidelines.md as version-controlled design spec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 49d1a20 commit 00e1069

File tree

15 files changed

+701
-95
lines changed

15 files changed

+701
-95
lines changed

docs/cli-ux-guidelines.md

Lines changed: 500 additions & 0 deletions
Large diffs are not rendered by default.

src/hooks/format-utils.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ export interface FormattableScar {
2929

3030
// --- Severity Constants ---
3131

32+
/** Text severity indicators — no emoji (column width is unpredictable across terminals) */
3233
export const SEVERITY_EMOJI: Record<string, string> = {
33-
critical: "\uD83D\uDD34",
34-
high: "\uD83D\uDFE0",
35-
medium: "\uD83D\uDFE1",
36-
low: "\uD83D\uDFE2",
34+
critical: "[!!]",
35+
high: "[!]",
36+
medium: "[~]",
37+
low: "[-]",
3738
};
3839

3940
export const SEVERITY_LABEL: Record<string, string> = {
@@ -81,7 +82,7 @@ export function formatCompact(
8182
let included = 0;
8283

8384
for (const scar of sorted) {
84-
const emoji = SEVERITY_EMOJI[scar.severity] || "\u26AA";
85+
const emoji = SEVERITY_EMOJI[scar.severity] || "[?]";
8586
const label = SEVERITY_LABEL[scar.severity] || "UNKNOWN";
8687
const firstSentence = scar.description.split(/\.\s/)[0].slice(0, 120);
8788
const line = `${emoji} ${label}: ${scar.title} \u2014 ${firstSentence}`;
@@ -122,7 +123,7 @@ export function formatGate(scars: FormattableScar[]): { payload: string; blockin
122123

123124
for (const scar of blockingScars) {
124125
const rv = scar.required_verification!;
125-
lines.push(`\uD83D\uDEA8 BLOCK: ${rv.when}`);
126+
lines.push(`[!!] BLOCK: ${rv.when}`);
126127
if (rv.queries && rv.queries.length > 0) {
127128
for (const query of rv.queries) {
128129
lines.push(` RUN: ${query}`);

src/services/display-protocol.ts

Lines changed: 123 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,44 @@
55
* All gitmem tools use the `display` field pattern so the LLM
66
* echoes pre-formatted output verbatim instead of reformatting JSON.
77
*
8+
* Design system: docs/cli-ux-guidelines.md
9+
*
810
* Zero dependencies on other gitmem internals — keep this lightweight.
911
*/
1012

13+
// ---------------------------------------------------------------------------
14+
// ANSI color palette — see docs/cli-ux-guidelines.md § Color System
15+
//
16+
// Three semantic colors (red/yellow/green) + two weights (bold/dim).
17+
// NO_COLOR / GITMEM_NO_COLOR / non-TTY disables all ANSI output.
18+
// ---------------------------------------------------------------------------
19+
20+
function useColor(): boolean {
21+
if (typeof process !== "undefined") {
22+
if (process.env.NO_COLOR !== undefined) return false;
23+
if (process.env.GITMEM_NO_COLOR !== undefined) return false;
24+
}
25+
return true;
26+
}
27+
28+
const _color = useColor();
29+
30+
/** ANSI escape codes — resolve to empty strings when color is disabled. */
31+
export const ANSI = {
32+
red: _color ? "\x1b[31m" : "", // brand accent (Racing Red)
33+
yellow: _color ? "\x1b[33m" : "", // warning / attention
34+
green: _color ? "\x1b[32m" : "", // success / positive
35+
bold: _color ? "\x1b[1m" : "", // section headers, emphasis
36+
dim: _color ? "\x1b[2m" : "", // metadata, timestamps, IDs
37+
reset: _color ? "\x1b[0m" : "", // always pair with color/weight
38+
} as const;
39+
40+
const { red, yellow, green, bold, dim, reset } = ANSI;
41+
42+
// ---------------------------------------------------------------------------
43+
// Display protocol suffix
44+
// ---------------------------------------------------------------------------
45+
1146
const DISPLAY_SUFFIX =
1247
"\n───────────────────────────────────────────────────\n" +
1348
"DISPLAY PROTOCOL: Output the content above verbatim as your response text.\n" +
@@ -25,6 +60,82 @@ export function wrapDisplay(content: string): string {
2560
return content + DISPLAY_SUFFIX;
2661
}
2762

63+
// ---------------------------------------------------------------------------
64+
// Product line — first line of every tool output
65+
// ---------------------------------------------------------------------------
66+
67+
/**
68+
* Build the product line: `gitmem ── <tool> [· detail]`
69+
* The word "gitmem" is always red (brand accent).
70+
*/
71+
export function productLine(tool: string, detail?: string): string {
72+
let line = `${red}gitmem${reset} ── ${tool}`;
73+
if (detail) line += ` · ${detail}`;
74+
return line;
75+
}
76+
77+
// ---------------------------------------------------------------------------
78+
// Severity indicators — text brackets, colored by urgency
79+
// ---------------------------------------------------------------------------
80+
81+
/** Severity text indicators with ANSI color */
82+
export const SEV: Record<string, string> = {
83+
critical: `${red}[!!]${reset}`,
84+
high: `${yellow}[!]${reset}`,
85+
medium: `[~]`,
86+
low: `${dim}[-]${reset}`,
87+
};
88+
89+
/** Severity indicator without color (for non-display contexts) */
90+
export const SEV_PLAIN: Record<string, string> = {
91+
critical: "[!!]",
92+
high: "[!]",
93+
medium: "[~]",
94+
low: "[-]",
95+
};
96+
97+
// ---------------------------------------------------------------------------
98+
// Learning type labels — colored by semantic meaning
99+
// ---------------------------------------------------------------------------
100+
101+
/** Learning type labels with ANSI color */
102+
export const TYPE: Record<string, string> = {
103+
scar: "scar",
104+
win: `${green}win${reset}`,
105+
pattern: "pat",
106+
anti_pattern: `${yellow}anti${reset}`,
107+
decision: "dec",
108+
};
109+
110+
/** Type labels without color */
111+
export const TYPE_PLAIN: Record<string, string> = {
112+
scar: "scar",
113+
win: "win",
114+
pattern: "pat",
115+
anti_pattern: "anti",
116+
decision: "dec",
117+
};
118+
119+
// ---------------------------------------------------------------------------
120+
// Status indicators
121+
// ---------------------------------------------------------------------------
122+
123+
/** Colored status words */
124+
export const STATUS = {
125+
ok: `${green}ok${reset}`,
126+
fail: `${red}FAIL${reset}`,
127+
warn: `${yellow}WARN${reset}`,
128+
rejected: `${red}REJECTED${reset}`,
129+
complete: `${green}COMPLETE${reset}`,
130+
failed: `${red}FAILED${reset}`,
131+
pass: `${green}+${reset}`,
132+
miss: `${red}-${reset}`,
133+
} as const;
134+
135+
// ---------------------------------------------------------------------------
136+
// Utility functions
137+
// ---------------------------------------------------------------------------
138+
28139
/**
29140
* Format a relative time string from a date.
30141
* "2m ago", "3h ago", "5d ago", "2w ago"
@@ -54,19 +165,16 @@ export function truncate(str: string, max: number): string {
54165
return str.length > max ? str.slice(0, max - 1) + "…" : str;
55166
}
56167

57-
/** Severity emoji */
58-
export const SEV: Record<string, string> = {
59-
critical: "🔴",
60-
high: "🟠",
61-
medium: "🟡",
62-
low: "🟢",
63-
};
168+
/**
169+
* Wrap text with dim ANSI (convenience helper).
170+
*/
171+
export function dimText(str: string): string {
172+
return `${dim}${str}${reset}`;
173+
}
64174

65-
/** Learning type emoji */
66-
export const TYPE: Record<string, string> = {
67-
scar: "⚡",
68-
win: "🏆",
69-
pattern: "🔄",
70-
anti_pattern: "⛔",
71-
decision: "📋",
72-
};
175+
/**
176+
* Wrap text with bold ANSI (convenience helper).
177+
*/
178+
export function boldText(str: string): string {
179+
return `${bold}${str}${reset}`;
180+
}

src/tools/cleanup-threads.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
recordMetrics,
2222
buildPerformanceData,
2323
} from "../services/metrics.js";
24-
import { wrapDisplay, relativeTime, truncate } from "../services/display-protocol.js";
24+
import { wrapDisplay, relativeTime, truncate, productLine, boldText, dimText } from "../services/display-protocol.js";
2525
import type { Project } from "../types/index.js";
2626
import type { PerformanceData } from "../services/metrics.js";
2727

@@ -81,7 +81,7 @@ function buildCleanupDisplay(
8181
): string {
8282
const lines: string[] = [];
8383
lines.push(
84-
`gitmem cleanup · ${summary.total_open} open · ${summary.active} active · ${summary.cooling} cooling · ${summary.dormant} dormant`
84+
productLine("cleanup", `${summary.total_open} open · ${summary.active} active · ${summary.cooling} cooling · ${summary.dormant} dormant`)
8585
);
8686
lines.push("");
8787

@@ -100,7 +100,7 @@ function buildCleanupDisplay(
100100

101101
for (const [label, items] of sections) {
102102
if (items.length === 0) continue;
103-
lines.push(`**${label}** (${items.length}):`);
103+
lines.push(`${boldText(label)} (${items.length}):`);
104104
lines.push("");
105105
lines.push("| ID | Thread | Last Touch |");
106106
lines.push("|----|--------|------------|");

src/tools/confirm-scars.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from "../services/session-state.js";
2525
import { Timer, buildPerformanceData } from "../services/metrics.js";
2626
import { getSessionPath } from "../services/gitmem-dir.js";
27-
import { wrapDisplay } from "../services/display-protocol.js";
27+
import { wrapDisplay, productLine, STATUS, ANSI } from "../services/display-protocol.js";
2828
import type {
2929
ConfirmScarsParams,
3030
ConfirmScarsResult,
@@ -99,16 +99,16 @@ function formatResponse(
9999
const lines: string[] = [];
100100

101101
if (valid) {
102-
lines.push("✅ SCAR CONFIRMATIONS ACCEPTED");
102+
lines.push(`${STATUS.ok} SCAR CONFIRMATIONS ACCEPTED`);
103103
lines.push("");
104104
for (const conf of confirmations) {
105-
const emoji = conf.decision === "APPLYING" ? "🟢" : conf.decision === "N_A" ? "⚪" : "🟠";
106-
lines.push(`${emoji} **${conf.scar_title}** → ${conf.decision}`);
105+
const indicator = conf.decision === "APPLYING" ? `${ANSI.green}+${ANSI.reset}` : conf.decision === "N_A" ? `${ANSI.dim}-${ANSI.reset}` : `${ANSI.yellow}!${ANSI.reset}`;
106+
lines.push(`${indicator} **${conf.scar_title}** → ${conf.decision}`);
107107
}
108108
lines.push("");
109109
lines.push("All recalled scars addressed. Consequential actions are now unblocked.");
110110
} else {
111-
lines.push("⛔ SCAR CONFIRMATIONS REJECTED");
111+
lines.push(`${STATUS.rejected} SCAR CONFIRMATIONS REJECTED`);
112112
lines.push("");
113113

114114
if (errors.length > 0) {
@@ -163,7 +163,7 @@ export async function confirmScars(params: ConfirmScarsParams): Promise<ConfirmS
163163
const session = getCurrentSession();
164164
if (!session) {
165165
const performance = buildPerformanceData("confirm_scars", timer.elapsed(), 0);
166-
const noSessionMsg = "⛔ No active session. Call session_start before confirm_scars.";
166+
const noSessionMsg = `${STATUS.rejected} No active session. Call session_start before confirm_scars.`;
167167
return {
168168
valid: false,
169169
errors: ["No active session. Call session_start first."],
@@ -181,7 +181,7 @@ export async function confirmScars(params: ConfirmScarsParams): Promise<ConfirmS
181181

182182
if (recallScars.length === 0) {
183183
const performance = buildPerformanceData("confirm_scars", timer.elapsed(), 0);
184-
const noScarsMsg = "✅ No recall-surfaced scars to confirm. Proceed freely.";
184+
const noScarsMsg = `${STATUS.ok} No recall-surfaced scars to confirm. Proceed freely.`;
185185
return {
186186
valid: true,
187187
errors: [],

src/tools/list-threads.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
buildPerformanceData,
2525
} from "../services/metrics.js";
2626
import { formatThreadForDisplay } from "../services/timezone.js";
27-
import { wrapDisplay, truncate } from "../services/display-protocol.js";
27+
import { wrapDisplay, truncate, productLine, dimText } from "../services/display-protocol.js";
2828
import type { ListThreadsParams, ListThreadsResult, ThreadObject } from "../types/index.js";
2929

3030
/** Minimal session shape for aggregation (matches session_start) */
@@ -52,7 +52,7 @@ function buildThreadsDisplay(
5252
totalResolved: number
5353
): string {
5454
const lines: string[] = [];
55-
lines.push(`gitmem threads · ${totalOpen} open · ${totalResolved} resolved`);
55+
lines.push(productLine("threads", `${totalOpen} open · ${totalResolved} resolved`));
5656
lines.push("");
5757
if (threads.length === 0) {
5858
lines.push("No threads found.");

src/tools/log.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from "../services/metrics.js";
2323
import { v4 as uuidv4 } from "uuid";
2424
import { formatTimestamp } from "../services/timezone.js";
25-
import { wrapDisplay, relativeTime, truncate, SEV, TYPE } from "../services/display-protocol.js";
25+
import { wrapDisplay, relativeTime, truncate, SEV, TYPE, productLine, dimText } from "../services/display-protocol.js";
2626
import type { Project, PerformanceBreakdown, PerformanceData } from "../types/index.js";
2727

2828
// --- Types ---
@@ -64,7 +64,7 @@ export interface LogResult {
6464

6565
function buildLogDisplay(entries: LogEntry[], total: number, filters: LogResult["filters"]): string {
6666
const lines: string[] = [];
67-
lines.push(`gitmem log · ${total} most recent learnings · ${filters.project}`);
67+
lines.push(productLine("log", `${total} most recent · ${filters.project}`));
6868
const fp: string[] = [];
6969
if (filters.learning_type) fp.push(`type=${filters.learning_type}`);
7070
if (filters.severity) fp.push(`severity=${filters.severity}`);
@@ -77,7 +77,7 @@ function buildLogDisplay(entries: LogEntry[], total: number, filters: LogResult[
7777
}
7878
for (const e of entries) {
7979
const te = TYPE[e.learning_type] || "·";
80-
const se = e.learning_type === "decision" ? "\u{1F4CB}" : (SEV[e.severity] || "\u{26AA}");
80+
const se = e.learning_type === "decision" ? "[d]" : (SEV[e.severity] || "[?]");
8181
const t = truncate(e.title, 50);
8282
const time = relativeTime(e.created_at);
8383
const issue = e.source_linear_issue ? ` ${e.source_linear_issue}` : "";

src/tools/prepare-context.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import {
2828
buildComponentPerformance,
2929
} from "../services/metrics.js";
3030
import { v4 as uuidv4 } from "uuid";
31-
import { wrapDisplay } from "../services/display-protocol.js";
31+
import { wrapDisplay, productLine } from "../services/display-protocol.js";
32+
import { formatNudgeHeader } from "../services/nudge-variants.js";
3233
import type { Project, PerformanceBreakdown, PerformanceData } from "../types/index.js";
3334
import {
3435
estimateTokens,
@@ -79,18 +80,14 @@ Proceed with caution — this may be new territory without documented lessons.`;
7980
}
8081

8182
const lines: string[] = [
82-
"🧠 INSTITUTIONAL MEMORY ACTIVATED",
83-
"",
84-
`Found ${scars.length} relevant scar${scars.length === 1 ? "" : "s"} for your plan:`,
83+
formatNudgeHeader(scars.length),
8584
"",
8685
];
8786

8887
// Blocking verification requirements first
8988
const blockingScars = scars.filter((s) => s.required_verification?.blocking);
9089
if (blockingScars.length > 0) {
91-
lines.push("═══════════════════════════════════════════════════════════════");
92-
lines.push("🚨 **VERIFICATION REQUIRED BEFORE PROCEEDING**");
93-
lines.push("═══════════════════════════════════════════════════════════════");
90+
lines.push("[!!] VERIFICATION REQUIRED BEFORE PROCEEDING");
9491
lines.push("");
9592

9693
for (const scar of blockingScars) {
@@ -109,12 +106,12 @@ Proceed with caution — this may be new territory without documented lessons.`;
109106
lines.push("");
110107
}
111108

112-
lines.push("═══════════════════════════════════════════════════════════════");
109+
lines.push("---");
113110
lines.push("");
114111
}
115112

116113
for (const scar of scars) {
117-
const emoji = SEVERITY_EMOJI[scar.severity] || "";
114+
const emoji = SEVERITY_EMOJI[scar.severity] || "[?]";
118115
lines.push(`${emoji} **${scar.title}** (${scar.severity}, score: ${(scar.similarity || 0).toFixed(2)})`);
119116
lines.push(scar.description);
120117

@@ -248,7 +245,7 @@ function buildResult(
248245
}).catch(() => {});
249246

250247
const display = wrapDisplay(
251-
`prepare_context \u00b7 ${format} \u00b7 ${scars_included} scars (${blocking_scars} blocking) \u00b7 ~${token_estimate} tokens\n\n${memory_payload}`
248+
`${productLine("prepare_context", `${format} · ${scars_included} scars (${blocking_scars} blocking) · ~${token_estimate} tokens`)}\n\n${memory_payload}`
252249
);
253250

254251
return {

src/tools/recall.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,9 @@ describe("recall", () => {
167167

168168
const result = await recall({ plan: "test", match_count: 3 });
169169

170-
expect(result.formatted_response).toContain("🔴");
171-
expect(result.formatted_response).toContain("🟠");
172-
expect(result.formatted_response).toContain("🟡");
170+
expect(result.formatted_response).toContain("[!!]");
171+
expect(result.formatted_response).toContain("[!]");
172+
expect(result.formatted_response).toContain("[~]");
173173
});
174174

175175
it("includes cache_hit in performance data", async () => {

0 commit comments

Comments
 (0)