Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 56 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,14 @@ wm draft.md -o draft.cleaned.md --json --audit
The install pulls no dependencies — the core is standard library only. Extras
are opt-in: `watermark-remover[visible]` for image inpainting,
`[quality]` for scoring, `[ai]` for the torch-backed adapters, `[provenance]`
for C2PA, `[tui]` for the terminal UI, or `[all]`. Five commands are installed:
for C2PA, or `[all]`. Five commands are installed:
`wm`, `wm-tui`, `wm-serve`, `wm-audit-dir`, `wm-audit-site`.

```bash
# Live Layer B rewrite through a local endpoint, in the same command
wm draft.md -o draft.cleaned.md \
--rewrite humanize --rewrite-backend openai-compatible \
--rewrite-base-url http://127.0.0.1:8000 --rewrite-model my-local-model

# Or drive the whole loop interactively
pip install "watermark-remover[tui]"
wm-tui ./drafts --recursive
```

### From a clone
Expand Down Expand Up @@ -116,62 +112,66 @@ python3 "$SCRIPTS/inspect_file.py" ./inputs --recursive --glob "*.md" --json
### Terminal UI

```bash
pip install "watermark-remover[tui]" # adds textual; the core stays dependency-free
wm-tui # opens the current directory
wm-tui # the current directory
wm-tui ./drafts --recursive --glob "*.md"
```

`wm-tui` is a front end over the same seam the CLI uses: it fills a
`CleanRequest`, runs it through `clean_request.plan_work` and
`clean_file.run_clean_item`, and inherits every refusal the CLI makes. It never
speaks HTTP itself and never displays or persists an API key.
`wm-tui` is a single-screen terminal UI in the style of opencode and pi. It is
built on [OpenTUI](https://github.com/anomalyco/opentui), so it needs
[Bun](https://bun.sh) 1.4 or later on the `PATH`. The Python install stays
dependency-free: on first launch `wm-tui` runs `bun install` for its own
frontend, and it prints the install hint if Bun is missing. macOS, Linux and
Windows are supported.

The screen has four parts:

- **The file list** on the left, which also shows progress and results.
- **The selected file** on the right: what was found, what was removed, and a
before/after diff.
- **One prompt** at the bottom:
- a path adds files;
- a `--flag` adds a `wm` option (any flag `wm` accepts, checked by the
CLI's own parser; type a bare flag again to turn it off);
- a `/command` runs a command.
- **The footer**, which always shows the exact `wm …` command a clean would
run.

| Key | Does |
| --- | --- |
| `ctrl+e` | inspect, which writes nothing |
| `ctrl+r` | clean; each file gets a `NAME.cleaned.EXT` beside it and originals stay untouched |
| `tab` | next preset: Hidden marks, Hidden marks aggressive, Deep clean (LLM rewrite), Images |
| `ctrl+p` | every command: model, history, doctor, setup, help |
| `esc` | stop after the current file |

It opens on **Start**, which is the whole job in three steps: add a file or a
folder, choose a preset, press Clean. Nothing has to be configured first, and
nothing about a preset is hidden — every option it sets is a visible control on
the Plan tab and appears in the equivalent `wm …` command.
**First run** opens a three-step setup:

| Preset | What it turns on | Result class |
| --- | --- | --- |
| Hidden marks | zero-width carriers, bidi controls, AI metadata — identical to a bare `wm FILE` | Verifiable |
| Hidden marks, aggressive | adds NFKC normalisation and homoglyph folding | Verifiable |
| Deep clean (LLM rewrite) | adds a local-model paraphrase; needs a Layer B endpoint | Best-effort |
| Images: metadata + degrade | strips C2PA/AI metadata, then perturbs the frequency domain | Best-effort |

No preset can set `--in-place`, `--strip-semantic-format` or `--dry-run`: those
overwrite the input, change what the text means, or replace the run with a
description, and each is a deliberate choice with its own confirmation.

The rest of Start is setup. The **Layer B endpoint** block sets the backend,
base URL and model and probes them; **Save setup** writes them to
`~/.config/watermark-remover/tui.json` (`$XDG_CONFIG_HOME` or `%APPDATA%` when
set, or `WATERMARKS_TUI_SETTINGS` to point somewhere else) so the next run
starts configured. The API key is never in that file — it is read from
`WATERMARKS_REWRITE_API_KEY` at run time and has no field to be written to.
**Installed capabilities** lists every optional extra and hands you the exact
`pip install` line for the missing ones.

The other panes: **Files** (select, rescan, glob and extension filters),
**Inspect** (Layer A carriers, metadata, stylometry, soft binding), **Plan**
(every option, plus the equivalent `wm …` command), **Run** (sequential batch,
per-file result table, live Layer B token stream, before/after diff),
**History** (every command this session generated, copyable and re-loadable).

The command-line arguments are `path`, `--recursive`, `--glob`, and
`--extensions` — the initial file selection; more paths can be added from
Start once it is running. Everything else is configured in the UI, and the
exact `wm` command it corresponds to is shown and copyable so a run can be
reproduced outside it.

Results are labeled by *layer*, never by outcome: Layer A and Layer M are
Verifiable, Layer B and Layer V are Best-effort, soft binding is
Detection-only. A run that stops to confirm — remote egress, `--in-place`,
`--strip-semantic-format`, or an expensive batch — does so before the first
write, not after.

Clipboard copy uses OSC 52, which some terminals (including macOS Terminal.app)
ignore without acknowledging. Every copy button is therefore paired with a
read-only, selectable text box holding the same string.
1. What the result classes mean.
2. A scan for a local model server. The scan only contacts `127.0.0.1`: it
checks Ollama, LM Studio, llama.cpp and vLLM for their model lists, and
never sends a document.
3. The default preset.

`esc` skips the setup, and it does not come back unless you run `/setup` or
`wm-tui --setup`. `--no-setup` never shows it.

The choices are saved to `~/.config/watermark-remover/tui.json`. The API key is
never in that file: it is read from `WATERMARKS_REWRITE_API_KEY` at run time
and never displayed.

Results are labelled by layer, never by outcome: Verifiable, Best-effort or
Detection-only. A clean that needs confirmation stops and asks before the
first write. That covers a remote endpoint, `--in-place`,
`--strip-semantic-format`, and a long LLM batch.

The UI is two processes:

- A Bun frontend (`skills/remove-ai-marks/tui/`) that only renders.
- A Python bridge (`scripts/tui_bridge.py`) that owns every decision. It builds
the request through `wm`'s own parser, `plan_work` and `run_clean_item`, so
it hits every refusal the CLI makes.

`tui/PROTOCOL.md` documents the protocol between them.

---

Expand Down
21 changes: 12 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,11 @@ ai = [
provenance = [
"c2pa-python",
]
# Interactive terminal UI (wm-tui). Textual brings rich; both are pure Python.
# Deliberately not in the core: `pip install watermark-remover` still pulls
# nothing. stdlib curses was rejected — it is absent on Windows, which this
# project supports, so "no dependency" would have cost windows-curses anyway.
tui = [
"textual>=0.80",
]
all = [
"watermark-remover[visible]",
"watermark-remover[quality]",
"watermark-remover[ai]",
"watermark-remover[provenance]",
"watermark-remover[tui]",
]

[project.scripts]
Expand All @@ -89,7 +81,18 @@ packages = ["watermark_remover", "watermark_remover.scripts"]
[tool.setuptools.package-data]
# Ship the skill documentation and bootstrap assets so a wheel install is
# self-sufficient for the CLI surfaces that read them at runtime.
"watermark_remover" = ["SKILL.md", "references/*.md"]
# The wm-tui frontend ships as source; the launcher runs `bun install` on
# first start (into a cache copy when site-packages is read-only).
"watermark_remover" = [
"SKILL.md",
"references/*.md",
"tui/package.json",
"tui/bun.lock",
"tui/bunfig.toml",
"tui/tsconfig.json",
"tui/src/**/*",
"tui/PROTOCOL.md",
]
"watermark_remover.scripts" = ["*.txt", "setup_*.sh", "setup_*.ps1"]

[tool.ruff]
Expand Down
2 changes: 0 additions & 2 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,3 @@ pypdf>=6.16.2,<7
ruff>=0.16.6,<1
# OpenAPI contract validation for wm-serve (CI validates /openapi.json).
openapi-spec-validator==0.9.0
# Interactive TUI (wm-tui) — installed so CI actually exercises the tui tests.
textual>=0.80
4 changes: 1 addition & 3 deletions skills/clean-user-facing-text/scripts/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,8 @@ def _configure_stdio() -> None:
):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
with suppress(OSError, ValueError):
reconfigure(encoding="utf-8", errors=errors)
except (OSError, ValueError):
pass


_configure_stdio()
Expand Down
4 changes: 2 additions & 2 deletions skills/clean-user-facing-text/scripts/inspect_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
# Allow running as script from any cwd
sys.path.insert(0, str(Path(__file__).resolve().parent))

from common import emit_json, read_text_input # noqa: E402
from text_unicode import human_report, inspect_text # noqa: E402
from common import emit_json, read_text_input
from text_unicode import human_report, inspect_text


def main() -> int:
Expand Down
19 changes: 9 additions & 10 deletions skills/remove-ai-marks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,16 +183,15 @@ Always include:

## Interactive surface

`wm-tui` (install `watermark-remover[tui]`) drives this same workflow with the
before/after evidence on screen: Layer A carrier counts, stylometry, and the
Layer B token stream side by side, plus the equivalent `wm …` command for every
run so the result is reproducible outside the UI. Its Start pane is the whole
job in three steps — add files, choose a preset, clean — and each preset names
its result class at the point of choice rather than only in the results. It fills a `CleanRequest` and
goes through `clean_request.plan_work` / `clean_file.run_clean_item`, so every
refusal documented here applies there unchanged. It never displays or persists
an API key, and it labels results by layer — Verifiable, Best-effort,
Detection-only — never by outcome.
`wm-tui` (it needs Bun) drives this same workflow from one screen:
- a file list that doubles as progress and results;
- the selected file's findings and before/after diff;
- a single prompt that takes paths, any `wm` flag, or `/commands`.

Its Python bridge builds every request through `clean_file`'s own parser and
runs it through `plan_work` and `run_clean_item`, so every refusal documented
here applies unchanged. It never displays or persists an API key. It labels
results by layer (Verifiable, Best-effort, Detection-only), never by outcome.

## Hard limits

Expand Down
6 changes: 2 additions & 4 deletions skills/remove-ai-marks/scripts/clean_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
)
from perturb_text import MODES as PERTURB_MODES
from perturb_text import perturb_text
from pipeline_actions import retarget_report
from rewrite_text import RewritePlan, TokenSink, rewrite
from text_unicode import clean_text

Expand Down Expand Up @@ -570,10 +571,7 @@ def _clean_image_asset(path: Path, dest: Path, plan: CleanPlan) -> CleanResult:
staged_mask_text = str(staged_mask)
final_mask_text = str(final_mask_output)
visible_report["mask"] = final_mask_text
visible_report["actions"] = [
action.replace(staged_mask_text, final_mask_text)
for action in visible_report["actions"]
]
retarget_report(visible_report, staged_mask_text, final_mask_text)

report["input"] = str(path)
if visible_report is not None:
Expand Down
47 changes: 12 additions & 35 deletions skills/remove-ai-marks/scripts/clean_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from asset_kind import SUPPORTED_EXTENSIONS
from batch_inputs import select_inputs
import pipeline_actions as act
from clean_asset import (
DEGRADE_CLI_CHOICES,
MORPHO_CLI_CHOICES,
Expand All @@ -39,6 +38,7 @@
describe_dropped_text_transforms,
dropped_text_transforms,
plan_work,
select_request_inputs,
)
from common import (
atomic_write_text,
Expand All @@ -48,6 +48,7 @@
from morphomod import VISIBLE_CLEAN_BACKENDS
from operation import ExitCode, OperationStatus, status_to_exit_code
from perturb_text import MODES as PERTURB_MODES
from pipeline_actions import report_actions
from rewrite_text import LIVE_REWRITE_BACKENDS, REASONING_EFFORTS, TokenSink, remote_warning

# Preserved under the old private name: external callers are not expected, but
Expand Down Expand Up @@ -246,32 +247,13 @@ def main() -> int:
except ValueError as error:
eprint(f"invalid options: {error}")
return ExitCode.USAGE_ERROR.value
allowed = request.allowed_extensions(SUPPORTED_EXTENSIONS)
excluded_roots = (
(request.output,)
if request.output
and not request.in_place
and any(source.is_dir() for source in request.paths)
else ()
)
try:
selection = select_inputs(
request.paths,
recursive=request.recursive,
pattern=request.glob,
extensions=allowed,
excluded_roots=excluded_roots,
)
selection = select_request_inputs(request)
except ValueError as error:
eprint(f"invalid input selection: {error}")
return ExitCode.USAGE_ERROR.value
items = selection.items
batch = selection.batch
if batch and (request.visible_mask or request.visible_box):
eprint(
"error: --visible-mask/--visible-box are single-file options; use --detect-command for batch"
)
return ExitCode.USAGE_ERROR.value
if request.in_place and request.output:
eprint("warning: -o ignored with --in-place")
try:
Expand Down Expand Up @@ -350,14 +332,14 @@ def dry_run_payload(
else:
localization = "external-detector"
actions = [
f"localize visible mark via {localization}",
f"fill holes + dilate radius={visible.dilation_radius}",
f"inpaint with {visible.backend} backend",
"strip requested metadata",
act.plan_localize(localization),
act.plan_refine_mask(visible.dilation_radius),
act.plan_inpaint(visible.backend),
act.plan_strip_metadata(),
]
if plan.degrade is not None:
actions.append(f"apply {plan.degrade.strategy} degradation")
actions.append(f"publish mask to {visible.mask_output} and image to {destination}")
actions.append(act.plan_degrade(plan.degrade.strategy))
actions.append(act.plan_publish(str(visible.mask_output), str(destination)))
return {
"kind": "image",
"status": "dry-run",
Expand All @@ -366,7 +348,7 @@ def dry_run_payload(
"mask": str(visible.mask_output),
"backend": visible.backend,
"timeout": visible.timeout,
"actions": actions,
**report_actions(actions),
"exit_code": ExitCode.SUCCESS.value,
}

Expand Down Expand Up @@ -405,7 +387,7 @@ def _error_payload(path: Path, output: Path, error: Exception) -> dict:
"kind": "unknown",
"input": str(path),
"output": str(output),
"actions": [f"error: {error}"],
**report_actions([act.failed(error)]),
"error": str(error),
"exit_code": status_to_exit_code(OperationStatus.FAILED),
}
Expand Down Expand Up @@ -478,10 +460,5 @@ def run_clean_item(
return payload


#: Preserved private aliases for in-tree callers predating the renames.
_run_clean_item = run_clean_item
_dry_run_payload = dry_run_payload


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading