show what the commands are actually saying

Install, uninstall and purge narrated what the platform was doing and hid what it
was running. Every spawn captured stdout and stderr, held them, and surfaced the
last 600 characters only if the exit code was non-zero.

That is enough to explain a crash and useless for the two things people want while
watching an install: knowing a slow step is still moving, and seeing the warning a
command prints on its way to exiting 0. The second is not hypothetical — `db:push`
reported success on a machine where it created no tables. Its output was captured,
discarded because the exit code was fine, and the failure surfaced minutes later as
42P01 in a sidecar log. Whatever drizzle said about that push, nobody ever read it.

`runLogged` streams both pipes as they arrive, so they interleave the way they do
in a terminal. Following only stdout would have been silent for exactly the
commands worth watching: git writes everything to stderr, drizzle-kit writes
progress there and results to stdout.

Two pieces of noise control, because a log nobody can read is not an improvement:
`git clone` gets `--progress` instead of `--quiet` (git says nothing when it thinks
it has no terminal, and that is the step where a slow link looks like a hang) with
per-percent rewrites collapsed to the last fragment per chunk; and drizzle's
spinner, which is eight lines saying the same thing with a different braille glyph,
collapses to the first — otherwise "Changes applied" scrolls past underneath it.

Steps and output share ONE ordered list rather than two. Their order relative to
each other is the whole information: "which step was this output from" is the only
question the log exists to answer. Output renders dimmer and indented under the
step that produced it.

Verified by running a real purge and reinstall: the clone's object counts, the
barrel generation naming both plugins, drizzle's push, then "schema: applied in
2029ms (1 table verified)".

tsgo clean, frontend builds, 873 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:01:07 +00:00
co-authored by Claude Opus 5
parent 3c03e61c5e
commit c98b5643aa
7 changed files with 179 additions and 40 deletions
@@ -10,6 +10,7 @@ import {
type AvailablePlugin,
type PluginActionResult,
type PluginDependencies,
type ActionEntry,
type PluginItem,
type PluginVerb,
} from './usePlugins';
@@ -136,7 +137,7 @@ const ActionLog = ({
result,
running,
}: {
steps: string[];
steps: ActionEntry[];
result: PluginActionResult | null;
running: PluginVerb | null;
}) => {
@@ -154,9 +155,14 @@ const ActionLog = ({
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
)}
</div>
<ul className="space-y-0.5 font-mono text-xs text-duck-dark/70">
{steps.map((s, i) => (
<li key={i}>· {s}</li>
{/* Raw output is dimmer and indented under the step that produced it — present when you want it,
not competing with the narration when you do not. `break-all` because a clone URL or a drizzle
error is one long token and would otherwise stretch the panel. */}
<ul className="max-h-64 space-y-0.5 overflow-y-auto font-mono text-xs">
{steps.map((entry, i) => (
<li key={i} className={entry.kind === 'step' ? 'text-duck-dark/70' : 'break-all pl-3 text-duck-dark/35'}>
{entry.kind === 'step' ? `· ${entry.text}` : entry.text}
</li>
))}
</ul>
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
@@ -190,7 +196,7 @@ const AvailableDetail = ({
plugin: AvailablePlugin;
busy: boolean;
onInstall: () => void;
steps: string[];
steps: ActionEntry[];
result: PluginActionResult | null;
running: PluginVerb | null;
}) => (
@@ -77,6 +77,15 @@ export type PluginActionResult = { ok: boolean; appName: string; steps: string[]
export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable' | 'purge';
/**
* One line in the action log.
*
* `step` is the platform narrating what it is doing. `log` is the raw output of whatever that step
* spawned — git, drizzle-kit, a package manager. Kept apart because they deserve different weight on
* screen, and kept in ONE list because their order relative to each other is the information.
*/
export type ActionEntry = { kind: 'step' | 'log'; text: string };
export type BrokenPlugin = { appName: string; error: string };
/**
@@ -133,7 +142,7 @@ export function usePlugins() {
queryClient.invalidateQueries({ queryKey: ['DOCK'] });
}, [queryClient]);
const [steps, setSteps] = useState<string[]>([]);
const [steps, setSteps] = useState<ActionEntry[]>([]);
const [result, setResult] = useState<PluginActionResult | null>(null);
const [running, setRunning] = useState<PluginVerb | null>(null);
const abort = useRef<AbortController | null>(null);
@@ -188,8 +197,12 @@ export function usePlugins() {
else if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
const parsed = JSON.parse(data) as { step?: string } & PluginActionResult;
if (event === 'step' && parsed.step) setSteps((prev) => [...prev, parsed.step!]);
const parsed = JSON.parse(data) as { step?: string; line?: string } & PluginActionResult;
if (event === 'step' && parsed.step) setSteps((prev) => [...prev, { kind: 'step', text: parsed.step! }]);
// One list, in arrival order. Two lists would be re-interleaved by render order rather than by
// when things happened, and "which step was this output from" is the only question the log
// exists to answer.
else if (event === 'log' && parsed.line) setSteps((prev) => [...prev, { kind: 'log', text: parsed.line! }]);
else if (event === 'done') setResult(parsed);
}
}