Clean Text

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"
  }
}
```

Markdown source · More bulbs by antypica · Typebulb home