Skip to content

Commit 116da46

Browse files
benbernardclaude
andcommitted
fix: cache invalidation, wire missing keybindings, and UX polish
Fix P0 bug where UPDATE_STAGE_ARGS and TOGGLE_STAGE did not invalidate cache entries, causing the inspector to show stale results after editing a stage's arguments or toggling enabled state. Cache invalidation now cascades to all downstream stages in the fork. Wire missing keybindings: x (export pipe script), X (ExportPicker), r (re-run from cursor), t (cycle inspector view), J/K (reorder stages), v (export records to temp file), Ctrl+C (quit). Remove Phase 2+ shortcuts (f, b, i, p) from HelpPanel and StatusBar. Additional UX fixes: auto-calculate RecordTable column widths from data, show "N of M records (X%)" ratio in InspectorHeader, increase pipeline panel width from 28 to 32. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 0f743bd commit 116da46

9 files changed

Lines changed: 281 additions & 22 deletions

File tree

src/tui/components/App.tsx

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import type { CliRenderer } from "@opentui/core";
1414
import type { TuiOptions } from "../index.tsx";
1515
import type { PipelineAction, StageConfig } from "../model/types.ts";
1616
import { pipelineReducer, createInitialState } from "../model/reducer.ts";
17-
import { getCursorStage } from "../model/selectors.ts";
17+
import { getCursorStage, getCursorOutput, getDownstreamStages } from "../model/selectors.ts";
18+
import { exportAsPipeScript, exportAsChainCommand, copyToClipboard } from "../model/serialization.ts";
19+
import { ExportPicker, type ExportFormat } from "./modals/ExportPicker.tsx";
1820
import { TitleBar } from "./TitleBar.tsx";
1921
import { StageList } from "./StageList.tsx";
2022
import { InspectorPanel } from "./InspectorPanel.tsx";
@@ -34,7 +36,8 @@ type ModalState =
3436
| { kind: "addStage" }
3537
| { kind: "editStage" }
3638
| { kind: "confirmDelete"; stageId: string }
37-
| { kind: "help" };
39+
| { kind: "help" }
40+
| { kind: "exportPicker" };
3841

3942
export function App({ options, renderer }: AppProps) {
4043
const hasInput = Boolean(options.inputFile || options.sessionId);
@@ -86,6 +89,36 @@ export function App({ options, renderer }: AppProps) {
8689
dispatch({ type: "TOGGLE_FOCUS" });
8790
return;
8891
}
92+
if (key.name === "c" && key.ctrl) {
93+
renderer.destroy();
94+
return;
95+
}
96+
if (key.raw === "x") {
97+
const script = exportAsPipeScript(state);
98+
void copyToClipboard(script).then((ok) => {
99+
showStatus(ok ? "Copied pipe script!" : "Export: clipboard failed");
100+
});
101+
return;
102+
}
103+
if (key.raw === "X") {
104+
setModal({ kind: "exportPicker" });
105+
return;
106+
}
107+
if (key.raw === "v") {
108+
const output = getCursorOutput(state);
109+
if (output && output.records.length > 0) {
110+
const tmpPath = `/tmp/recs-${Date.now()}.jsonl`;
111+
const jsonl = output.records.map((r) => JSON.stringify(r.toJSON())).join("\n") + "\n";
112+
void Bun.write(tmpPath, jsonl).then(() => {
113+
void copyToClipboard(tmpPath).then(() => {
114+
showStatus(`Records exported to ${tmpPath}`);
115+
});
116+
});
117+
} else {
118+
showStatus("No records to export");
119+
}
120+
return;
121+
}
89122

90123
// Pipeline panel keys
91124
if (state.focusedPanel === "pipeline") {
@@ -119,6 +152,29 @@ export function App({ options, renderer }: AppProps) {
119152
}
120153
return;
121154
}
155+
if (key.raw === "r") {
156+
if (state.cursorStageId) {
157+
dispatch({ type: "INVALIDATE_STAGE", stageId: state.cursorStageId });
158+
const downstream = getDownstreamStages(state, state.cursorStageId);
159+
for (const s of downstream) {
160+
dispatch({ type: "INVALIDATE_STAGE", stageId: s.id });
161+
}
162+
showStatus("Re-running from cursor...");
163+
}
164+
return;
165+
}
166+
if (key.raw === "J") {
167+
if (state.cursorStageId) {
168+
dispatch({ type: "REORDER_STAGE", stageId: state.cursorStageId, direction: "down" });
169+
}
170+
return;
171+
}
172+
if (key.raw === "K") {
173+
if (state.cursorStageId) {
174+
dispatch({ type: "REORDER_STAGE", stageId: state.cursorStageId, direction: "up" });
175+
}
176+
return;
177+
}
122178
if (key.name === "return" || key.name === "tab") {
123179
dispatch({ type: "TOGGLE_FOCUS" });
124180
return;
@@ -131,6 +187,13 @@ export function App({ options, renderer }: AppProps) {
131187
dispatch({ type: "TOGGLE_FOCUS" });
132188
return;
133189
}
190+
if (key.raw === "t") {
191+
const modes = ["table", "prettyprint", "json"] as const;
192+
const currentIdx = modes.indexOf(state.inspector.viewMode as typeof modes[number]);
193+
const nextIdx = (currentIdx + 1) % modes.length;
194+
dispatch({ type: "SET_VIEW_MODE", viewMode: modes[nextIdx]! });
195+
return;
196+
}
134197
}
135198
});
136199

@@ -174,6 +237,31 @@ export function App({ options, renderer }: AppProps) {
174237
setModal({ kind: "none" });
175238
}, [modal, showStatus]);
176239

240+
const handleExportFormat = useCallback(
241+
(format: ExportFormat) => {
242+
setModal({ kind: "none" });
243+
if (format === "pipe-script") {
244+
const text = exportAsPipeScript(state);
245+
void copyToClipboard(text).then((ok) => {
246+
showStatus(ok ? "Copied pipe script!" : "Export: clipboard failed");
247+
});
248+
} else if (format === "chain-command") {
249+
const text = exportAsChainCommand(state);
250+
void copyToClipboard(text).then((ok) => {
251+
showStatus(ok ? "Copied chain command!" : "Export: clipboard failed");
252+
});
253+
} else {
254+
// save-file
255+
const script = exportAsPipeScript(state);
256+
const tmpPath = `/tmp/recs-pipeline-${Date.now()}.sh`;
257+
void Bun.write(tmpPath, script).then(() => {
258+
showStatus(`Saved to ${tmpPath}`);
259+
});
260+
}
261+
},
262+
[state, showStatus],
263+
);
264+
177265
if (!hasInput) {
178266
return (
179267
<box flexDirection="column" width="100%" height="100%">
@@ -230,6 +318,12 @@ export function App({ options, renderer }: AppProps) {
230318
{modal.kind === "help" && (
231319
<HelpPanel onClose={() => setModal({ kind: "none" })} />
232320
)}
321+
{modal.kind === "exportPicker" && (
322+
<ExportPicker
323+
onSelect={handleExportFormat}
324+
onCancel={() => setModal({ kind: "none" })}
325+
/>
326+
)}
233327
</box>
234328
);
235329
}

src/tui/components/InspectorHeader.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { PipelineState } from "../model/types.ts";
2-
import { getCursorStage, getCursorOutput } from "../model/selectors.ts";
2+
import { getCursorStage, getCursorOutput, getActivePath, getStageOutput } from "../model/selectors.ts";
33

44
export interface InspectorHeaderProps {
55
state: PipelineState;
@@ -41,7 +41,21 @@ export function InspectorHeader({ state }: InspectorHeaderProps) {
4141
);
4242
}
4343

44-
const countStr = output ? `${output.recordCount} records` : "not cached";
44+
// Get total record count from first stage for ratio display
45+
const activePath = getActivePath(state);
46+
const firstStage = activePath[0];
47+
const firstOutput = firstStage ? getStageOutput(state, firstStage.id) : undefined;
48+
const totalRecords = firstOutput?.recordCount;
49+
50+
let countStr: string;
51+
if (!output) {
52+
countStr = "not cached";
53+
} else if (totalRecords && totalRecords > 0 && output.recordCount !== totalRecords) {
54+
const pct = Math.round((output.recordCount / totalRecords) * 100);
55+
countStr = `${output.recordCount} of ${totalRecords} records (${pct}%)`;
56+
} else {
57+
countStr = `${output.recordCount} records`;
58+
}
4559
const cacheAge = output ? `, cached ${formatCacheAge(output.computedAt)}` : "";
4660

4761
return (

src/tui/components/RecordTable.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,29 @@ export function RecordTable({
2626
scrollOffset + maxRows,
2727
);
2828

29+
// Auto-calculate column widths from field names + data
30+
const COL_MIN = 4;
31+
const COL_MAX = 30;
32+
const colWidths = fields.map((field) => {
33+
let maxWidth = field.length;
34+
for (const record of visibleRecords) {
35+
const val = record.get(field);
36+
const str = val === null || val === undefined ? "" : String(val);
37+
maxWidth = Math.max(maxWidth, str.length);
38+
}
39+
return Math.min(Math.max(maxWidth, COL_MIN), COL_MAX);
40+
});
41+
2942
// Build header
30-
const header = "# " + fields.map((f) => f.padEnd(15).slice(0, 15)).join(" ");
43+
const header = "# " + fields.map((f, i) => f.padEnd(colWidths[i]!).slice(0, colWidths[i]!)).join(" ");
3144

3245
// Build rows
3346
const rows = visibleRecords.map((record, idx) => {
3447
const rowNum = String(scrollOffset + idx + 1).padStart(3);
35-
const cells = fields.map((field) => {
48+
const cells = fields.map((field, i) => {
3649
const val = record.get(field);
3750
const str = val === null || val === undefined ? "" : String(val);
38-
return str.padEnd(15).slice(0, 15);
51+
return str.padEnd(colWidths[i]!).slice(0, colWidths[i]!);
3952
});
4053
return `${rowNum} ${cells.join(" ")}`;
4154
});

src/tui/components/StageList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function StageList({ state }: StageListProps) {
1313

1414
return (
1515
<box
16-
width={28}
16+
width={32}
1717
flexDirection="column"
1818
borderStyle="single"
1919
borderColor={isFocused ? "#FFFFFF" : "#555555"}

src/tui/components/StatusBar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function StatusBar({ state, statusMessage }: StatusBarProps) {
1212
// Context-sensitive keybindings based on focused panel
1313
const keys =
1414
state.focusedPanel === "pipeline"
15-
? "a:add d:del e:edit u:undo x:export f:fork v:vim ?:help q:quit"
15+
? "a:add d:del e:edit J/K:move x:export v:vim u:undo ?:help q:quit"
1616
: "↑↓:scroll t:view /:search Tab:back";
1717

1818
return (

src/tui/components/modals/HelpPanel.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,29 +20,27 @@ PIPELINE (left panel)
2020
d Delete stage (with confirm)
2121
e Edit stage arguments
2222
Space Toggle stage enabled/disabled
23+
J/K Reorder stage down/up
24+
r Re-run from cursor stage
25+
v Export records to temp file
26+
x Export pipeline → clipboard
27+
X Export pipeline (choose format)
2328
Enter/Tab Focus inspector panel
2429
2530
INSPECTOR (right panel)
2631
↑/k, ↓/j Scroll records
2732
PgUp/PgDn Page scroll
28-
t Cycle view: table → prettyprint → json → schema
33+
t Cycle view: table → prettyprint → json
2934
/ Search records
3035
Esc/Tab Return to pipeline
3136
3237
GLOBAL
3338
Tab Toggle focus: pipeline ↔ inspector
34-
f Fork at cursor
35-
b Switch fork branch
36-
i Switch input file
37-
r Re-run from first stale stage
38-
v Open records in $EDITOR
39-
x Export pipeline (shell pipe script → clipboard)
40-
X Export pipeline (choose format)
4139
u Undo last pipeline edit
4240
Ctrl+R Redo last undone edit
43-
p Pin/unpin stage for selective caching
41+
Ctrl+C Quit
4442
? Toggle this help
45-
q Quit (auto-saves session)`;
43+
q Quit`;
4644

4745
export function HelpPanel({ onClose }: HelpPanelProps) {
4846
useKeyboard((key) => {

src/tui/model/reducer.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
Stage,
66
StageId,
77
Fork,
8+
CachedResult,
89
CacheConfig,
910
InspectorState,
1011
} from "./types.ts";
@@ -316,7 +317,9 @@ export function pipelineReducer(
316317
config: { ...stage.config, args: [...action.args] },
317318
});
318319

319-
return { ...state, stages };
320+
const cache = invalidateStageAndDownstream(state.cache, state.forks, stage);
321+
322+
return { ...state, stages, cache };
320323
}
321324

322325
// ── Toggle stage enabled ────────────────────────────────────
@@ -330,7 +333,9 @@ export function pipelineReducer(
330333
config: { ...stage.config, enabled: !stage.config.enabled },
331334
});
332335

333-
return { ...state, stages };
336+
const cache = invalidateStageAndDownstream(state.cache, state.forks, stage);
337+
338+
return { ...state, stages, cache };
334339
}
335340

336341
// ── Reorder stage ───────────────────────────────────────────
@@ -536,6 +541,13 @@ export function pipelineReducer(
536541
state.focusedPanel === "pipeline" ? "inspector" : "pipeline",
537542
};
538543

544+
// ── Inspector view mode ─────────────────────────────────────
545+
case "SET_VIEW_MODE":
546+
return {
547+
...state,
548+
inspector: { ...state.inspector, viewMode: action.viewMode },
549+
};
550+
539551
default:
540552
return state;
541553
}
@@ -577,6 +589,37 @@ function rebuildLinksForFork(
577589
}
578590
}
579591

592+
/**
593+
* Remove cache entries for a stage and all downstream stages in the same fork.
594+
* Called when a stage's config changes (args update, toggle enabled) to ensure
595+
* the inspector doesn't show stale cached results.
596+
*/
597+
function invalidateStageAndDownstream(
598+
cache: Map<string, CachedResult>,
599+
forks: Map<string, Fork>,
600+
stage: Stage,
601+
): Map<string, CachedResult> {
602+
const fork = forks.get(stage.forkId);
603+
if (!fork) return cache;
604+
605+
const idx = fork.stageIds.indexOf(stage.id);
606+
if (idx === -1) return cache;
607+
608+
// This stage + all stages after it in the fork
609+
const toInvalidate = new Set(fork.stageIds.slice(idx));
610+
611+
const newCache = new Map(cache);
612+
for (const [key] of newCache) {
613+
for (const sid of toInvalidate) {
614+
if (key.endsWith(`:${sid}`)) {
615+
newCache.delete(key);
616+
break;
617+
}
618+
}
619+
}
620+
return newCache;
621+
}
622+
580623
/**
581624
* Pre-check whether an action would be a no-op so we can skip the undo checkpoint.
582625
* Returns true if the action should be short-circuited (return state unchanged).

src/tui/model/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ export type PipelineAction =
129129
| { type: "SET_ERROR"; stageId: StageId; message: string }
130130
| { type: "CLEAR_ERROR" }
131131
| { type: "SET_EXECUTING"; executing: boolean }
132-
| { type: "TOGGLE_FOCUS" };
132+
| { type: "TOGGLE_FOCUS" }
133+
| { type: "SET_VIEW_MODE"; viewMode: InspectorState["viewMode"] };
133134

134135
// ── File Size Warning ─────────────────────────────────────────────
135136

0 commit comments

Comments
 (0)