A retro two-pane file manager where the cursor picks files, a copy dialog fills its progress bar and the files land in the other pane.
╔═════════════════════ C:\ ══════════════════════╗╔══════════════════ A:\BACKUP ═══════════════════╗ ║ Name │ Size │ Date ║║ Name │ Size │ Date ║ ║ .. │ UP--DIR │ ║║ .. │ UP--DIR │ ║ ║ DRIVERS │ SUB-DIR │ 03-14-26 ║║ ARCHIVE │ SUB-DIR │ 08-08-26 ║ ║ GAMES │ SUB-DIR │ 06-02-26 ║║ BUDGET TXT │ 2140 │ 10-01-26 ║ ║ UTILS │ SUB-DIR │ 01-09-26 ║║ NOTES TXT │ 980 │ 10-12-26 ║ ║ AUTOEXEC BAT │ 512 │ 09-30-26 ║║ │ │ ║ ║ CONFIG SYS │ 389 │ 09-30-26 ║║ │ │ ║ ║ EDIT COM │ 69886 │ 04-09-94 ║║ │ │ ║ ║ MOUSE COM │ 564╔════════════════════ Copy ════════════════════════╗ │ ║ ║ ZIPPER EXE │ 412║ ║ │ ║ ║ README TXT │ 82║ Copying the file ║ │ ║ ║ REPORT DOC │ 245║ C:\README.TXT ║ │ ║ ║▐SALES░░░░XLS░░│░░░░317║ to A:\BACKUP\README.TXT ║ │ ║ ║ BLOCKS BAS │ 120║ ███░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 8% ║ │ ║ ║ │ ║ █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 3% ║ │ ║ ║ │ ║ Total 1793 of 64531 bytes ║ │ ║ ║ │ ╚══════════════════════════════════════════════════╝ │ ║ ║ │ │ ║║ │ │ ║ ║ │ │ ║║ │ │ ║ ║ │ │ ║║ │ │ ║ ║ │ │ ║║ │ │ ║ ║ │ │ ║║ │ │ ║ ║ │ │ ║║ │ │ ║ ╟────────────────────────────────────────────────╢╟────────────────────────────────────────────────╢ ║ SALES.XLS ║║ .. ║ ╚════════════════════════════════════════════════╝╚════════════════════════════════════════════════╝ C:\> 1Help 2Menu 3View 4Edit 5Copy 6RenMov 7Mkdir 8Delete 9PullDn 10Quit 3 files selected, 64,531 bytes
Paste two lines into any page, play it in a terminal, or put a GIF in a README. The code follows the look you pick above.
<script type="module" src="https://www.ascii-magic.com/ascii/ascii.js"></script>
<ascii-art piece="twin-pane-commander"></ascii-art>// copy twin-pane-commander.js and kit.js into your project, then:
import { useEffect, useRef } from "react";
import create from "./twin-pane-commander.js";
export function Piece() {
const ref = useRef(null);
useEffect(() => {
const draw = create(), t0 = performance.now();
let raf;
const tick = () => {
const f = draw((performance.now() - t0) / 1000);
ref.current.textContent = f.text ?? f;
raf = requestAnimationFrame(tick);
};
tick();
return () => cancelAnimationFrame(raf);
}, []);
return <pre ref={ref} style={{ lineHeight: 1, fontFamily: "Menlo, Consolas, monospace" }} />;
}npx --yes https://www.ascii-magic.com/ascii/cli.tgz twin-pane-commander<!-- Download GIF below, commit it, then: -->
<p align="center">
<img src="assets/twin-pane-commander.gif" alt="Twin Pane Commander, animated ASCII art" width="720">
</p>Pause on the frame you like, pick a look, then download it at an exact size.
MIT licensed. It imports one small helper file, kit.js.
/* twin-pane-commander: a retro two-pane file manager copying a folder across with a progress dialog. */
import { Grid, palette } from "../lib/kit.js";
export const meta = {
slug: "twin-pane-commander",
name: "Twin Pane Commander",
category: "screens",
note: "A retro two-pane file manager where the cursor picks files, a copy dialog fills its progress bar and the files land in the other pane.",
tags: ["retro", "dos", "file-manager", "terminal", "files", "nostalgia"],
cols: 100, rows: 30, fps: 20,
palette: palette(["#e6edf3", "#8b949e", "#7ee787", "#79c0ff", "#ffa657", "#ff7b72", "#d2a8ff", "#484f58", "#30363d", "#f2cc60", "#56d4dd", "#1f6feb"]),
};
const LEFT = [
["..", "UP--DIR", "", true], ["DRIVERS", "SUB-DIR", "03-14-26", true], ["GAMES", "SUB-DIR", "06-02-26", true], ["UTILS", "SUB-DIR", "01-09-26", true],
["AUTOEXEC.BAT", "512", "09-30-26"], ["CONFIG.SYS", "389", "09-30-26"], ["EDIT.COM", "69886", "04-09-94"],
["MOUSE.COM", "56408", "03-10-93"], ["ZIPPER.EXE", "41245", "02-01-93"], ["README.TXT", "8211", "10-17-26"],
["REPORT.DOC", "24576", "10-15-26"], ["SALES.XLS", "31744", "10-16-26"], ["BLOCKS.BAS", "12010", "07-22-95"],
];
const RIGHT0 = [["..", "UP--DIR", "", true], ["ARCHIVE", "SUB-DIR", "08-08-26", true], ["BUDGET.TXT", "2140", "10-01-26"], ["NOTES.TXT", "980", "10-12-26"]];
const PICK = [9, 10, 11];
const PERIOD = 15;
export default function create() {
const g = new Grid(meta.cols, meta.rows);
const W = meta.cols, H = meta.rows;
const PW = W / 2;
// cursor path in left pane
const curAt = (lt) => {
if (lt < 1) return 0;
if (lt < 5.2) return Math.min(11, Math.floor((lt - 1) / 0.35));
return 11;
};
const drawPane = (x0, title, rows, cur, active, marked, fresh) => {
g.box(x0, 0, PW, H - 3, "double", "3");
g.text(x0 + Math.floor(PW / 2 - title.length / 2) - 1, 0, " " + title + " ", active ? "9" : "a");
g.text(x0 + 2, 1, "Name".padEnd(14), "9"); g.set(x0 + 16, 1, "│", "3");
g.text(x0 + 18, 1, "Size".padStart(8), "9"); g.set(x0 + 27, 1, "│", "3");
g.text(x0 + 29, 1, "Date", "9");
for (let y = 1; y < H - 6; y++) { g.set(x0 + 16, y, "│", "3"); g.set(x0 + 27, y, "│", "3"); }
rows.forEach(([n, s, d, dir], i) => {
const y = 2 + i;
if (y >= H - 6) return;
const sel = active && i === cur;
const mk = marked.includes(i);
const c = sel ? "0" : mk ? "9" : fresh.includes(n) ? "2" : dir ? "0" : "a";
if (sel) { g.set(x0 + 1, y, "▐", "b"); g.set(x0 + PW - 2, y, "▌", "b"); }
g.text(x0 + 2, y, (dir ? n : n.split(".")[0]).padEnd(9) + (dir ? "" : (n.split(".")[1] || "").padEnd(4)), c);
g.text(x0 + 18, y, s.padStart(8), c);
g.text(x0 + 29, y, d, c);
if (sel) for (let k = x0 + 2; k < x0 + PW - 2; k++) if (g.get(k, y) === " ") g.set(k, y, "░", "b");
});
g.hline(x0 + 1, x0 + PW - 2, H - 6, "─", "3");
g.set(x0, H - 6, "╟", "3"); g.set(x0 + PW - 1, H - 6, "╢", "3");
const r = rows[active ? cur : 0];
g.text(x0 + 2, H - 5, r[0], "a");
};
return (t) => {
g.clear();
const lt = t % PERIOD;
const cur = curAt(lt);
const marked = PICK.filter((i, k) => lt >= 3.9 + k * 0.35 + 0.1 && lt < 10.6);
const copyP = Math.max(0, Math.min(1, (lt - 6.4) / 3.6));
const right = RIGHT0.slice();
const landed = PICK.filter((i, k) => copyP > (k + 1) / PICK.length - 0.01);
landed.forEach((i) => right.push(LEFT[i]));
const fresh = lt < 12.5 ? landed.map((i) => LEFT[i][0]) : [];
const leftActive = lt < 11.5;
drawPane(0, "C:\\", LEFT, cur, leftActive, marked, []);
drawPane(PW, "A:\\BACKUP", right, Math.min(right.length - 1, 4 + Math.floor((lt - 11.5) / 0.5)), !leftActive, [], fresh);
// command line
g.text(0, H - 3, "C:\\>", "0");
const cmdKeys = lt >= 5.6 && lt < 6.4 ? "copy" : "";
g.text(4, H - 3, cmdKeys, "9");
if (Math.floor(lt * 3) % 2 === 0) g.set(4 + cmdKeys.length, H - 3, "_", "0");
// dialog
if (lt >= 5.6 && lt < 10.6) {
const bx = 24, by = 9, bw = 52, bh = 9;
g.fill(bx, by, bw, bh, " ");
g.box(bx, by, bw, bh, "double", "0");
g.text(bx + 21, by, " Copy ", "9");
if (lt < 6.4) {
g.text(bx + 3, by + 2, "Copy 3 files to", "0");
g.text(bx + 3, by + 3, "A:\\BACKUP\\" + "_".repeat(30), "a");
g.text(bx + 12, by + 6, "[ Copy ]", "9"); g.text(bx + 30, by + 6, "[ Cancel ]", "1");
} else {
const fi = Math.min(PICK.length - 1, Math.floor(copyP * PICK.length));
const fp = Math.min(1, copyP * PICK.length - fi);
g.text(bx + 3, by + 2, "Copying the file", "0");
g.text(bx + 3, by + 3, "C:\\" + LEFT[PICK[fi]][0], "9");
g.text(bx + 3, by + 4, "to A:\\BACKUP\\" + LEFT[PICK[fi]][0], "9");
const bw2 = bw - 14;
const n1 = Math.round(fp * bw2), n2 = Math.round(copyP * bw2);
g.text(bx + 3, by + 5, "█".repeat(n1), "a"); g.text(bx + 3 + n1, by + 5, "░".repeat(bw2 - n1), "8"); g.text(bx + bw - 9, by + 5, String(Math.round(fp * 100)).padStart(3) + "%", "0");
g.text(bx + 3, by + 6, "█".repeat(n2), "2"); g.text(bx + 3 + n2, by + 6, "░".repeat(bw2 - n2), "8"); g.text(bx + bw - 9, by + 6, String(Math.round(copyP * 100)).padStart(3) + "%", "0");
g.text(bx + 3, by + 7, `Total ${Math.round(copyP * 64531)} of 64531 bytes`, "1");
}
}
// function keys
const F = ["Help", "Menu", "View", "Edit", "Copy", "RenMov", "Mkdir", "Delete", "PullDn", "Quit"];
let x = 0;
F.forEach((n, i) => {
const k = String(i + 1);
g.text(x, H - 2, k, "0");
g.text(x + k.length, H - 2, n.padEnd(8), i === 4 && lt >= 5.6 && lt < 6.4 ? "9" : "a");
x += 10;
});
g.text(0, H - 1, `${marked.length ? marked.length + " files selected, 64,531 bytes" : "Bytes free: 1,412,096 on A:"}`, "1");
return g.frame();
};
}
/YOUR OWN
The pieces here are hand-built animations. To turn your own photo, logo or video into animated ASCII, use the ASCII Magic editor.