Clean up dumped text — strip terminal artifacts, reflow lines, fix whitespace. AI mode handles arbitrary mess.
---
format: typebulb/v1
name: Clean Text
---
**code.tsx**
```tsx
import React, { useState, useMemo, useEffect } from "react";
import { createRoot } from "react-dom/client";
type Mode = "none" | "cli";
interface Model {
provider: string;
name: string;
friendlyName: string;
}
// The Claude Code CLI gutter prefix (U+258E LEFT ONE QUARTER BLOCK),
// optionally surrounded by whitespace at the start of a line.
const PREFIX_RE = /^\s*▎\s?/;
function cleanCli(input: string): string {
if (!input) return "";
// Strip the "▎" prefix from each line.
const stripped = input.split(/\r?\n/).map((line) => line.replace(PREFIX_RE, ""));
// Normalize whitespace: preserve leading indentation, collapse internal runs,
// drop trailing whitespace.
const cleaned = stripped.map((line) => {
const leading = line.match(/^[ \t]*/)?.[0] ?? "";
const rest = line.slice(leading.length).replace(/[ \t]+/g, " ").trimEnd();
return leading + rest;
});
// Rejoin artificially-wrapped lines into paragraphs, but leave fenced
// code blocks alone.
const out: string[] = [];
let para: string[] = [];
let inCode = false;
const flush = () => {
if (para.length > 0) {
out.push(para.join(" "));
para = [];
}
};
for (const line of cleaned) {
const trimmed = line.trim();
if (trimmed.startsWith("```")) {
flush();
out.push(line);
inCode = !inCode;
continue;
}
if (inCode) {
out.push(line);
continue;
}
if (trimmed === "") {
flush();
out.push("");
} else {
para.push(trimmed);
}
}
flush();
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
function autoClean(input: string, mode: Mode): string {
if (mode === "none") return input;
return cleanCli(input);
}
// If the AI wraps its entire response in a single ```...``` fence, peel it.
function stripOuterFence(s: string): string {
const t = s.trim();
const m = t.match(/^```(?:[a-z]+)?\n([\s\S]*?)\n?```$/i);
return m ? m[1] : t;
}
const SYSTEM_PROMPT = `You are a text cleaner. Your job is structural cleanup only — never paraphrase, summarize, or change meaning or wording.
The user will provide sloppy text (from a CLI, AI chat, log file, etc). They may include an instruction at the top in the form "INSTRUCTION: ..." followed by "---" and the text. Apply the instruction if present; otherwise do general structural cleanup:
- Strip terminal/CLI gutter characters (▎, leading | or > prefixes that aren't blockquotes)
- Strip leading line numbers (e.g. " 123 | ", "123:") if they look like editor/log line numbers
- Rejoin lines hard-wrapped at terminal width into proper paragraphs
- Collapse runs of double-spaces, drop trailing whitespace, collapse 3+ blank lines into one
Always preserve:
- Fenced code blocks (\`\`\`...\`\`\`) verbatim — never reflow code, never strip leading whitespace from code lines
- Indented code blocks, list structure, headings, paragraph breaks
- The user's exact wording
Output ONLY the cleaned text. No preamble, no commentary, no "Here is the cleaned text:", no markdown fences wrapping the whole response.`;
const MODES: { id: Mode; label: string }[] = [
{ id: "none", label: "None" },
{ id: "cli", label: "CLI" },
];
const MODEL_STORAGE_KEY = "clean-text-model";
// Preferred default for AI Clean: cheap + fast + good enough for structural cleanup.
const PREFERRED_DEFAULT = { provider: "openrouter", name: "google/gemini-3.1-flash-lite" };
function App() {
const [input, setInput] = useState("");
const [mode, setMode] = useState<Mode>("cli");
const [instruction, setInstruction] = useState("");
const [aiOutput, setAiOutput] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [copied, setCopied] = useState(false);
const [models, setModels] = useState<Model[]>([]);
const [model, setModel] = useState<Model | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
tb.models().then((available: Model[]) => {
setModels(available);
const saved = localStorage.getItem(MODEL_STORAGE_KEY);
const savedMatch = saved ? available.find((m) => `${m.provider}:${m.name}` === saved) : null;
const preferred = available.find(
(m) => m.provider === PREFERRED_DEFAULT.provider && m.name === PREFERRED_DEFAULT.name
);
setModel(savedMatch || preferred || available[0] || null);
});
}, []);
const selectModel = (m: Model) => {
setModel(m);
localStorage.setItem(MODEL_STORAGE_KEY, `${m.provider}:${m.name}`);
};
const computed = useMemo(() => autoClean(input, mode), [input, mode]);
const output = aiOutput ?? computed;
const handleInputChange = (val: string) => {
setInput(val);
if (aiOutput !== null) setAiOutput(null);
if (error) setError(null);
};
const handleAiClean = async () => {
if (!input.trim() || loading || !model) return;
setLoading(true);
setError(null);
try {
const userMessage = instruction.trim()
? `INSTRUCTION: ${instruction.trim()}\n\n---\n\n${input}`
: input;
const resp = await tb.ai({
provider: model.provider,
model: model.name,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMessage }],
});
const cleaned = stripOuterFence(resp.text);
setAiOutput(cleaned);
} catch (e: any) {
setError(e.message || "AI clean failed");
} finally {
setLoading(false);
}
};
const handleCopy = async () => {
if (!output) return;
await tb.copy(output);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
const grouped = useMemo(() => {
const g: Record<string, Model[]> = {};
for (const m of models) (g[m.provider] ??= []).push(m);
return g;
}, [models]);
return (
<div className="app">
<header className="topbar">
<h1>Clean Text</h1>
<div className="mode-group" role="group" aria-label="Auto-clean mode">
{MODES.map((m) => (
<button
key={m.id}
className={`mode-btn ${mode === m.id ? "mode-active" : ""}`}
onClick={() => setMode(m.id)}
title={m.id === "none" ? "Don't auto-clean" : "Clean CLI gutter & reflow"}
>
{m.label}
</button>
))}
</div>
<input
className="instruction"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
placeholder="(Optional) Tell the AI what to fix..."
onKeyDown={(e) => {
if (e.key === "Enter") handleAiClean();
}}
/>
{models.length > 1 ? (
<select
className="model-select"
value={model ? `${model.provider}:${model.name}` : ""}
onChange={(e) => {
const [provider, name] = e.target.value.split(":");
const m = models.find((mm) => mm.provider === provider && mm.name === name);
if (m) selectModel(m);
}}
title="AI model"
>
{Object.entries(grouped).map(([provider, ms]) => (
<optgroup key={provider} label={provider}>
{ms.map((m) => (
<option key={m.name} value={`${m.provider}:${m.name}`}>
{m.friendlyName}
</option>
))}
</optgroup>
))}
</select>
) : null}
<button
className="ai-btn"
onClick={handleAiClean}
disabled={!input.trim() || loading || !model}
title={!model ? "No AI model available" : "Clean with AI"}
>
{loading ? "Cleaning..." : "AI Clean"}
</button>
<button onClick={handleCopy} disabled={!output} className="copy-btn">
{copied ? "Copied!" : "Copy"}
</button>
</header>
{error && <div className="error">{error}</div>}
<main className="panes">
<section className="pane">
<div className="pane-label">Sloppy input</div>
<textarea
className="ta"
value={input}
onChange={(e) => handleInputChange(e.target.value)}
placeholder="Paste sloppy text here..."
spellCheck={false}
autoFocus
/>
</section>
<section className="pane">
<div className="pane-label">
Cleaned
{aiOutput !== null && <span className="badge">AI</span>}
</div>
<textarea
className="ta"
value={output}
readOnly
spellCheck={false}
placeholder="Cleaned text will appear here."
/>
</section>
</main>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);
```
**styles.css**
```css
:root {
--bg: #f7f7f8;
--fg: #1a1a1a;
--muted: #6b7280;
--border: #e5e7eb;
--card: #ffffff;
--accent: #4f46e5;
--accent-fg: #ffffff;
--ai: #a855f7;
--err-bg: #fef2f2;
--err-fg: #b91c1c;
}
html[data-theme="dark"] {
--bg: #0f0f10;
--fg: #ececec;
--muted: #9ca3af;
--border: #2a2a2e;
--card: #18181b;
--accent: #818cf8;
--accent-fg: #0f0f10;
--ai: #c084fc;
--err-bg: #2a1414;
--err-fg: #f87171;
}
* { box-sizing: border-box; }
html, body, #root {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.app {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
gap: 12px;
}
/* Topbar */
.topbar {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
flex-wrap: wrap;
}
.topbar h1 {
margin: 0;
font-size: 1.3rem;
font-weight: 800;
letter-spacing: -0.01em;
margin-right: 4px;
}
/* Mode toggle */
.mode-group {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
background: var(--card);
}
.mode-btn {
background: transparent;
border: none;
border-right: 1px solid var(--border);
padding: 6px 14px;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
cursor: pointer;
}
.mode-btn:last-child { border-right: none; }
.mode-btn:hover { color: var(--fg); }
.mode-active {
background: var(--accent);
color: var(--accent-fg);
}
.mode-active:hover { color: var(--accent-fg); }
/* Instruction input */
.instruction {
flex: 1;
min-width: 180px;
max-width: 500px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--fg);
font-size: 0.85rem;
font-family: inherit;
outline: none;
}
.instruction:focus { border-color: var(--ai); }
/* Model select */
.model-select {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--card);
color: var(--fg);
font-size: 0.85rem;
font-family: inherit;
outline: none;
cursor: pointer;
max-width: 180px;
}
.model-select:focus { border-color: var(--ai); }
/* Buttons */
.ai-btn, .copy-btn {
border: none;
border-radius: 6px;
padding: 6px 14px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.ai-btn {
background: var(--ai);
color: #ffffff;
}
.ai-btn:hover:not(:disabled) { opacity: 0.9; }
.copy-btn {
background: var(--accent);
color: var(--accent-fg);
}
.copy-btn:hover:not(:disabled) { opacity: 0.9; }
.ai-btn:disabled, .copy-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Error banner */
.error {
background: var(--err-bg);
color: var(--err-fg);
border: 1px solid var(--err-fg);
padding: 8px 12px;
border-radius: 6px;
font-size: 0.85rem;
flex-shrink: 0;
}
/* Panes */
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
flex: 1;
min-height: 0;
}
@media (max-width: 700px) {
.panes { grid-template-columns: 1fr; }
}
.pane {
display: flex;
flex-direction: column;
min-height: 0;
}
.pane-label {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.badge {
background: var(--ai);
color: #ffffff;
font-size: 0.65rem;
font-weight: 800;
padding: 1px 6px;
border-radius: 4px;
letter-spacing: 0.05em;
}
.ta {
flex: 1;
width: 100%;
resize: none;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
color: var(--fg);
font-family: ui-monospace, "SF Mono", Consolas, monospace;
font-size: 0.85rem;
line-height: 1.5;
outline: none;
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
}
.ta:focus { border-color: var(--accent); }
.ta[readonly] { background: color-mix(in srgb, var(--card) 96%, var(--fg) 4%); }
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"description": "Clean up dumped text — strip terminal artifacts, reflow lines, fix whitespace. AI mode handles arbitrary mess.",
"dependencies": {
"react": "^19.2.3",
"react-dom": "^19.2.3"
}
}
```