tickmarkr 1.66.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,7 @@ import { ROUTING_MODES } from "../../config/config.js";
4
4
  import { pickDriver } from "../../drivers/index.js";
5
5
  import { loadGraph } from "../../graph/graph.js";
6
6
  import { formatSummary, resolveRunMode, runDaemon } from "../../run/daemon.js";
7
+ import { isRunLockLive } from "../../run/lock.js";
7
8
  import { route, NO_EXPLORE_ENV } from "../../route/router.js";
8
9
  import { formatJournalNarration, loadRoutingProfile } from "../../run/journal.js";
9
10
  import { ttyVisual } from "../../adapters/model-lints.js";
@@ -65,6 +66,14 @@ export async function run(argv, cwd = process.cwd()) {
65
66
  throw new Error(`--route-strict: refusing to dispatch — ${conflict}`);
66
67
  console.warn(`tickmarkr: !! ${conflict}`);
67
68
  }
69
+ // OBS-107 (v1.67 T5): advisory only — a stale compiled graph with prior terminal statuses and no
70
+ // live daemon will otherwise finish with zero work and confuse the operator. The recompile remedy
71
+ // is named; dispatch proceeds unchanged.
72
+ const TERMINAL_STATUSES = ["done", "failed", "human"];
73
+ if (graph.tasks.some((t) => TERMINAL_STATUSES.includes(t.status)) && !isRunLockLive(cwd)) {
74
+ console.warn("tickmarkr: compiled graph carries terminal statuses from a prior run and no daemon is active — "
75
+ + "run `tickmarkr compile <src>` to recompile; proceeding with stale graph");
76
+ }
68
77
  const noExplore = !!values["no-explore"];
69
78
  const exploreCtx = noExplore ? { noExplore } : undefined;
70
79
  if (noExplore)
@@ -60,8 +60,6 @@ const GATE_STATE_TOKEN = { pass: ok, fail, skip: dim, open: dim };
60
60
  const gateChain = (states, unicode) => GATE_NAMES.map((gate, i) => unicode
61
61
  ? GATE_STATE_TOKEN[states[i]](gateBox(states[i], true))
62
62
  : `${GATE_KEYS[gate]}${gateBox(states[i], false)}`).join(" ");
63
- // plain (uncolored) cell width for column math — ANSI codes have zero display width
64
- const gateChainWidth = (unicode) => GATE_NAMES.reduce((w, gate) => w + (unicode ? 1 : GATE_KEYS[gate].length + 3) + 1, -1);
65
63
  const failedGates = (states) => GATE_NAMES.filter((_, i) => states[i] === "fail");
66
64
  // plain-text form of the failed-gate words for column math; rendered with a dim dot + red names
67
65
  const failedSuffix = (states) => {
@@ -182,10 +180,13 @@ const renderFrame = (cwd) => {
182
180
  const done = effective.tasks.filter((t) => t.status === "done").length;
183
181
  const cells = g.tasks.map((t) => {
184
182
  const st = replayed?.get(t.id) ?? t.status;
185
- const label = starved.has(t.id) ? " starved" : activity.cells.has(t.id) ? ` ${activity.cells.get(t.id)}` : "";
183
+ const isStarved = starved.has(t.id);
184
+ const phrase = isStarved ? undefined : activity.cells.get(t.id);
185
+ const label = isStarved ? " starved" : phrase ? ` ${phrase}` : "";
186
186
  const channel = assignments.get(t.id) ?? "-";
187
- const assignCol = contexts.has(t.id) ? `${channel}${divider}ctx ${contexts.get(t.id)}` : channel;
188
- return { t, st, label, assignCol, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
187
+ const ctx = contexts.get(t.id);
188
+ const assignCol = ctx !== undefined ? `${channel}${divider}ctx ${ctx}` : channel;
189
+ return { t, st, label, assignCol, isStarved, phrase, channel, ctx, states: comparable ? gateStates(t, events) : defaultGateStates(t) };
189
190
  });
190
191
  if (!unicode) {
191
192
  // machine/CI surface — layout unchanged (pipes, greps, and the golden pins depend on it)
@@ -227,26 +228,39 @@ const renderFrame = (cwd) => {
227
228
  // rows: the line names task ids, and rows must stay the first id-bearing lines for grep consumers.
228
229
  const nowLine = activity.now ? [legend(` now: ${activity.now}`)] : [];
229
230
  const gatesLegend = legend(` gates: ${GATE_NAMES.join(" · ")}`);
231
+ // Two-line card per task (operator request, v1.67): line 1 carries identity + verdict — glyph,
232
+ // id, goal at full width, and only SHORT status words. Line 2 carries the machinery, dim and
233
+ // aligned under the goal: gate chain, live activity phrase (or channel), ctx. Long channel names
234
+ // and activity phrases live on line 2 only, so they can never squeeze the goal or wrap line 1.
230
235
  const taskVerdict = (st) => st === "done" ? "pass" : st === "failed" ? "fail" : st === "human" ? "warn" : "neutral";
231
236
  const idW = Math.max(...cells.map((c) => c.t.id.length), 2);
232
- const stW = Math.max(...cells.map((c) => (String(c.st) + c.label + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states)).length));
233
- const chainW = gateChainWidth(true);
234
- const assignW = Math.max(...cells.map((c) => c.assignCol.length));
235
- const goalW = Math.max(8, width - (5 + idW) - 2 - chainW - 2 - stW - 2 - assignW);
236
- const rows = cells.map(({ t, st, label, assignCol, states }) => {
237
- const goal = shortGoal(t.goal, goalW).padEnd(goalW);
237
+ // plain-text status suffixes for width math only — starved / failed gate names / approval hint
238
+ const suffixPlain = (c) => (c.isStarved ? " · starved" : "") + failedSuffix(c.states) + humanGateSuffix(c.t, c.st, c.states);
239
+ const stW = Math.max(...cells.map((c) => String(c.st).length + suffixPlain(c).length));
240
+ const avail = Math.max(8, width - (5 + idW) - 2 - stW);
241
+ const goals = cells.map((c) => shortGoal(c.t.goal, avail));
242
+ const goalW = Math.max(8, ...goals.map((s) => s.length));
243
+ const indent = " ".repeat(idW + 5); // line 2 starts under the goal column
244
+ const rows = cells.map(({ t, st, states, isStarved, phrase, channel, ctx }, i) => {
238
245
  const stWord = st === "done" ? ok(String(st)) : st === "failed" ? fail(String(st)) : st === "human" ? warn(String(st)) : String(st);
239
246
  // a fail names its gate in words right here — the one moment gate identity is needed on a row
240
247
  const f = failedGates(states);
241
248
  const human = humanGateSuffix(t, st, states);
242
249
  const statusCell = stWord +
243
- (label ? (label === " starved" ? fail(label) : dim(label)) : "") +
244
- (f.length ? dim(" · ") + fail(f.join(", ")) : "") +
245
- (human ? dim(" · ") + warn("awaiting approval") : "") +
246
- " ".repeat(stW - (String(st) + label + failedSuffix(states) + human).length);
247
- return ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goal} ${gateChain(states, true)} ${statusCell} ${dim(assignCol)}`)}`;
248
- });
249
- return [header, hr, gatesLegend, ...rows, ...nowLine].join("\n");
250
+ (isStarved ? dot + fail("starved") : "") +
251
+ (f.length ? dot + fail(f.join(", ")) : "") +
252
+ (human ? dot + warn("awaiting approval") : "");
253
+ const line1 = ` ${statusRow(taskVerdict(st), `${t.id.padEnd(idW)} ${goals[i].padEnd(goalW)} ${statusCell}`)}`;
254
+ // activity already names its channel for in-flight attempts — never repeat it
255
+ const detail = [
256
+ ...(phrase ? [phrase, ...(phrase.includes(channel) || channel === "-" ? [] : [channel])] : [channel]),
257
+ ...(ctx !== undefined ? [`ctx ${ctx}`] : []),
258
+ ].join(" · ");
259
+ return [line1, `${indent}${gateChain(states, true)} ${dim(detail)}`, ""];
260
+ }).flat();
261
+ if (!nowLine.length)
262
+ rows.pop(); // cards end blank-separated; drop the dangling one
263
+ return [header, hr, gatesLegend, "", ...rows, ...nowLine].join("\n");
250
264
  };
251
265
  export async function status(argv, cwd = process.cwd(), opts = {}) {
252
266
  // cockpit surface: banner + frame on a TTY (doctor's pattern); pipes get the bare frame
@@ -4,6 +4,7 @@ import { type Task } from "../graph/schema.js";
4
4
  * Each line names the task id and at least one missing collateral test path.
5
5
  */
6
6
  export declare function collateralLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
7
+ export declare function newDirectoryLints(tasks: ReadonlyArray<Pick<Task, "id" | "files">>, repoRoot: string): string[];
7
8
  /**
8
9
  * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
9
10
  * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
@@ -145,20 +145,69 @@ function criteriaSymbols(acceptance) {
145
145
  }
146
146
  return [...out].sort();
147
147
  }
148
+ const ARCH_PAGES = ["docs/codebase/ARCHITECTURE.md", "docs/codebase/STRUCTURE.md"];
149
+ function topLevelSrcDir(file) {
150
+ const parts = file.replace(/^\.\//, "").replace(/\/+$/, "").split("/");
151
+ if (parts[0] !== "src" || parts.length < 2 || !parts[1])
152
+ return undefined;
153
+ return parts[1];
154
+ }
155
+ function existingSrcTopLevels(repoRoot) {
156
+ const out = new Set();
157
+ let entries;
158
+ try {
159
+ entries = readdirSync(join(repoRoot, "src"), { withFileTypes: true });
160
+ }
161
+ catch {
162
+ return out;
163
+ }
164
+ for (const e of entries) {
165
+ if (e.isDirectory())
166
+ out.add(e.name);
167
+ }
168
+ return out;
169
+ }
170
+ // OBS-108 (v1.67 T5): a task whose files[] creates a new top-level src/ directory must also
171
+ // include the architecture pages the docs-truth suite pins. Advisory only — never blocks compile.
172
+ export function newDirectoryLints(tasks, repoRoot) {
173
+ const existing = existingSrcTopLevels(repoRoot);
174
+ const lines = [];
175
+ for (const t of tasks) {
176
+ // OBS-22: scopeGate accepts picomatch globs; advisory warnings must agree.
177
+ const scoped = picomatch(t.files.map((f) => f.replace(/^\.\//, "")), { dot: true });
178
+ const newDirs = new Set();
179
+ for (const f of t.files) {
180
+ const dir = topLevelSrcDir(f);
181
+ if (!dir || existing.has(dir))
182
+ continue;
183
+ newDirs.add(`src/${dir}/`);
184
+ }
185
+ if (!newDirs.size)
186
+ continue;
187
+ const missing = ARCH_PAGES.filter((p) => !scoped(p));
188
+ if (!missing.length)
189
+ continue;
190
+ const dirList = [...newDirs].sort().join(", ");
191
+ const dirLabel = newDirs.size > 1 ? "directories" : "directory";
192
+ lines.push(`${t.id}: new top-level source ${dirLabel} ${dirList} must include ${missing.join(" and ")} in files[]`);
193
+ }
194
+ return lines;
195
+ }
148
196
  /**
149
197
  * OBS-76 class: sweep src/ for out-of-scope source files that reference a symbol the acceptance
150
198
  * criteria name — the v1.52 router.ts omission, named at plan time instead of one judge round in.
151
199
  * Advisory plan output only, same contract as collateralLints.
152
200
  */
153
201
  export function sourceScopeLints(tasks, repoRoot) {
202
+ const newDirLints = newDirectoryLints(tasks, repoRoot);
154
203
  const perTask = tasks
155
204
  .map((t) => ({ t, needles: criteriaSymbols(t.acceptance) }))
156
205
  .filter((x) => x.needles.length);
157
206
  if (!perTask.length)
158
- return [];
207
+ return newDirLints;
159
208
  const srcFiles = walkCode(repoRoot, "src");
160
209
  if (!srcFiles.length)
161
- return [];
210
+ return newDirLints;
162
211
  const read = makeReader(repoRoot);
163
212
  const lines = [];
164
213
  for (const { t, needles } of perTask) {
@@ -183,5 +232,5 @@ export function sourceScopeLints(tasks, repoRoot) {
183
232
  const tail = hits.length > MAX_HITS_PER_TASK ? " (capped)" : "";
184
233
  lines.push(`${t.id}: criteria implicate out-of-scope source not in files[]: ${listed}${tail}`);
185
234
  }
186
- return lines;
235
+ return [...newDirLints, ...lines];
187
236
  }
@@ -72,16 +72,23 @@ export async function runGates(task, ctx) {
72
72
  // worker attempt — exactly what this fix forbids; only its meta-carries-channel pattern is mirrored.
73
73
  // Straight-line single `if` — NO loop/counter/knob: exactly-once by construction (a knob is a
74
74
  // fail-closed weakening vector); a second garbage verdict fails the gate closed exactly as today.
75
+ // T6: exclusion mirrors consult reroute semantics — the flaked channel's whole adapter is banned, not
76
+ // just its exact channel key, so an outage window cannot re-select the vendor being routed around.
77
+ // If no other adapter is live, the exclusion degrades to a channel-level reroute within the same
78
+ // adapter so a single-adapter fleet still retries (matching the daemon's unknown-excludeAdapter
79
+ // degradation path).
75
80
  if (a.meta?.unparseable === true && typeof a.meta.judge === "string") {
76
81
  const flakedKey = a.meta.judge;
77
- const candidate = ctx.channels
78
- .filter((c) => channelKey(c) !== flakedKey)
82
+ const flakedAdapter = flakedKey.slice(0, flakedKey.indexOf(":"));
83
+ const pick = (pool) => pool
79
84
  // pickReviewer's sort (review.ts:37): TIER_RANK desc, marginalCostRank asc — proven ordering; both
80
- // symbols already imported by a sibling gate file. No vendor-diversity axis (the judge isn't review).
85
+ // symbols already imported by a sibling gate file.
81
86
  .sort((x, y) => TIER_RANK[y.tier] - TIER_RANK[x.tier] || marginalCostRank(x) - marginalCostRank(y))[0];
82
- // ponytail: same-channel fallback when the fleet has no alternative (D-03). One judge call against a
83
- // deterministic garbage source is wasted, bounded; fail-closed unchanged on a second garbage verdict.
84
- const retry = candidate ?? { adapter: ctx.cfg.judge.adapter, model: ctx.cfg.judge.model };
87
+ const crossAdapter = pick(ctx.channels.filter((c) => c.adapter !== flakedAdapter));
88
+ const sameAdapter = pick(ctx.channels.filter((c) => c.adapter === flakedAdapter && channelKey(c) !== flakedKey));
89
+ // Prefer a different adapter; if the fleet only has one adapter, retry on a different channel of
90
+ // that adapter; if the fleet has only one channel, fall back to the original judge config.
91
+ const retry = crossAdapter ?? sameAdapter ?? { adapter: ctx.cfg.judge.adapter, model: ctx.cfg.judge.model };
85
92
  const retryAdapter = getAdapter(retry.adapter, ctx.adapters);
86
93
  const retryJvia = ctx.via
87
94
  // unconditional -r1 suffix: under keepPanes:forever a same-channel retry cannot collide with the
package/dist/tui/app.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { type OutputStream } from "../tui/engine.js";
2
2
  import type { InputStream } from "../tui/input.js";
3
+ import type { FleetEditable } from "../config/config.js";
4
+ import { type FleetEdit } from "./staging.js";
3
5
  export type View = {
4
6
  id: string;
5
7
  label: string;
@@ -11,13 +13,26 @@ export type View = {
11
13
  export type StudioOptions = {
12
14
  input: InputStream;
13
15
  output: OutputStream;
16
+ /** Loaded fleet state the buffer wraps. Defaults to an empty editable for the no-arg shell path. */
17
+ loaded?: FleetEditable;
18
+ /** Repository root used for overlay paths and live-run detection. Defaults to cwd. */
19
+ repoRoot?: string;
20
+ /** Global config directory override. */
21
+ globalDir?: string;
14
22
  };
15
23
  export declare class StudioApp {
16
24
  private engine;
17
25
  private views;
18
26
  private active;
19
27
  private showingHelp;
28
+ private confirmingQuit;
29
+ private showingSave;
30
+ private saveTarget;
31
+ private saveProposal;
20
32
  private notice;
33
+ private staging;
34
+ private repoRoot;
35
+ private globalDir;
21
36
  private _lines;
22
37
  private resolveExit;
23
38
  readonly exited: Promise<void>;
@@ -30,11 +45,25 @@ export declare class StudioApp {
30
45
  start(): void;
31
46
  /** Restore the terminal and release the input stream. */
32
47
  stop(): void;
48
+ /** Apply an edit to the staging buffer and repaint. Intended as the test seam and future edit hook. */
49
+ stageEdit(edit: FleetEdit): void;
50
+ private get isModalOpen();
33
51
  private bindKeys;
52
+ private handleEscape;
53
+ private handleQuit;
54
+ private handleSaveOpen;
55
+ private handleSaveToggle;
56
+ private handleConfirm;
57
+ private handleCancel;
58
+ private executeSave;
59
+ private closeSave;
60
+ private handleRevert;
34
61
  private setView;
35
62
  private showNotice;
36
63
  private paint;
37
64
  private buildFrame;
38
65
  private renderTabBar;
66
+ private renderStatusBar;
39
67
  private renderHelp;
68
+ private renderQuitConfirm;
40
69
  }
package/dist/tui/app.js CHANGED
@@ -3,14 +3,33 @@ import { createFleetView } from "./views/fleet-view.js";
3
3
  import { createPreviewView } from "./views/preview-view.js";
4
4
  import { createProfileView } from "./views/profile-view.js";
5
5
  import { createRoutingView } from "./views/routing-view.js";
6
- const MUTATION_KEYS = ["r", "s", "m", "return"];
6
+ import { renderDiffModal } from "./views/diff-modal.js";
7
+ import { buildSaveProposal, confirmSave } from "./save.js";
8
+ import { FleetStaging } from "./staging.js";
9
+ const MUTATION_KEYS = ["r", "m", "return"];
7
10
  const READONLY_NOTICE = "read-only in v1.66 — write path ships in v1.67";
11
+ function emptyFleetEditable() {
12
+ return {
13
+ denyAdapters: [],
14
+ denyModels: [],
15
+ tiers: {},
16
+ map: {},
17
+ floors: {},
18
+ };
19
+ }
8
20
  export class StudioApp {
9
21
  engine;
10
22
  views;
11
23
  active = 0;
12
24
  showingHelp = false;
25
+ confirmingQuit = false;
26
+ showingSave = false;
27
+ saveTarget = "repo";
28
+ saveProposal = null;
13
29
  notice = null;
30
+ staging;
31
+ repoRoot;
32
+ globalDir;
14
33
  _lines = [];
15
34
  resolveExit = null;
16
35
  exited;
@@ -20,6 +39,9 @@ export class StudioApp {
20
39
  output: opts.output,
21
40
  onResize: () => this.paint(),
22
41
  });
42
+ this.repoRoot = opts.repoRoot ?? process.cwd();
43
+ this.globalDir = opts.globalDir;
44
+ this.staging = new FleetStaging(opts.loaded ?? emptyFleetEditable());
23
45
  this.views = [createFleetView(), createRoutingView(), createPreviewView(), createProfileView()];
24
46
  this.exited = new Promise((resolve) => {
25
47
  this.resolveExit = resolve;
@@ -47,33 +69,179 @@ export class StudioApp {
47
69
  this.resolveExit = null;
48
70
  }
49
71
  }
72
+ /** Apply an edit to the staging buffer and repaint. Intended as the test seam and future edit hook. */
73
+ stageEdit(edit) {
74
+ this.staging.apply(edit);
75
+ this.paint();
76
+ }
77
+ get isModalOpen() {
78
+ return this.showingHelp || this.confirmingQuit || this.showingSave;
79
+ }
50
80
  bindKeys() {
51
81
  // view switching
52
82
  // Tab is decoded as the raw "\t" sequence by the micro-engine.
53
- this.engine.key("\t", () => this.setView((this.active + 1) % this.views.length));
83
+ this.engine.key("\t", () => {
84
+ if (!this.isModalOpen)
85
+ this.setView((this.active + 1) % this.views.length);
86
+ });
54
87
  for (let i = 1; i <= this.views.length; i++) {
55
88
  const idx = i - 1;
56
- this.engine.key(String(i), () => this.setView(idx));
89
+ this.engine.key(String(i), () => {
90
+ if (!this.isModalOpen)
91
+ this.setView(idx);
92
+ });
57
93
  }
58
94
  // help overlay
59
95
  this.engine.key("?", () => {
60
- this.showingHelp = true;
61
- this.paint();
62
- });
63
- // dismissal / quit
64
- this.engine.key("escape", () => {
65
- if (this.showingHelp) {
66
- this.showingHelp = false;
96
+ if (!this.confirmingQuit && !this.showingSave) {
97
+ this.showingHelp = true;
67
98
  this.paint();
68
99
  }
69
- else {
70
- this.stop();
71
- }
72
100
  });
73
- this.engine.key("q", () => this.stop());
74
- // mutation keys — read-only until the v1.67 write path
101
+ // dismissal / quit
102
+ this.engine.key("escape", () => this.handleEscape());
103
+ this.engine.key("q", () => this.handleQuit());
104
+ // revert staged changes
105
+ this.engine.key("u", () => this.handleRevert());
106
+ // save modal
107
+ this.engine.key("s", () => this.handleSaveOpen());
108
+ this.engine.key("t", () => this.handleSaveToggle());
109
+ // quit-confirmation / save-confirmation answers
110
+ this.engine.key("y", () => this.handleConfirm());
111
+ this.engine.key("n", () => this.handleCancel());
112
+ // remaining mutation keys — read-only until wired
75
113
  for (const key of MUTATION_KEYS) {
76
- this.engine.key(key, () => this.showNotice());
114
+ this.engine.key(key, () => {
115
+ if (!this.isModalOpen)
116
+ this.showNotice();
117
+ });
118
+ }
119
+ }
120
+ handleEscape() {
121
+ if (this.showingHelp) {
122
+ this.showingHelp = false;
123
+ this.paint();
124
+ }
125
+ else if (this.showingSave) {
126
+ this.closeSave(false);
127
+ }
128
+ else if (this.confirmingQuit) {
129
+ this.confirmingQuit = false;
130
+ this.paint();
131
+ }
132
+ else if (this.staging.isDirty) {
133
+ this.confirmingQuit = true;
134
+ this.paint();
135
+ }
136
+ else {
137
+ this.stop();
138
+ }
139
+ }
140
+ handleQuit() {
141
+ if (this.showingHelp || this.showingSave)
142
+ return; // modals block q
143
+ if (this.confirmingQuit) {
144
+ this.stop();
145
+ return;
146
+ }
147
+ if (this.staging.isDirty) {
148
+ this.confirmingQuit = true;
149
+ this.paint();
150
+ }
151
+ else {
152
+ this.stop();
153
+ }
154
+ }
155
+ handleSaveOpen() {
156
+ if (this.isModalOpen)
157
+ return;
158
+ if (!this.staging.isDirty) {
159
+ this.notice = "no staged changes";
160
+ this.paint();
161
+ return;
162
+ }
163
+ this.showingSave = true;
164
+ this.saveTarget = "repo";
165
+ this.saveProposal = buildSaveProposal({
166
+ repoRoot: this.repoRoot,
167
+ loaded: this.staging.loadedState,
168
+ staged: this.staging.current,
169
+ target: this.saveTarget,
170
+ globalDir: this.globalDir,
171
+ });
172
+ this.paint();
173
+ }
174
+ handleSaveToggle() {
175
+ if (!this.showingSave)
176
+ return;
177
+ this.saveTarget = this.saveTarget === "repo" ? "global" : "repo";
178
+ this.saveProposal = buildSaveProposal({
179
+ repoRoot: this.repoRoot,
180
+ loaded: this.staging.loadedState,
181
+ staged: this.staging.current,
182
+ target: this.saveTarget,
183
+ globalDir: this.globalDir,
184
+ });
185
+ this.paint();
186
+ }
187
+ handleConfirm() {
188
+ if (this.showingSave) {
189
+ this.executeSave();
190
+ return;
191
+ }
192
+ if (this.confirmingQuit) {
193
+ this.stop();
194
+ }
195
+ }
196
+ handleCancel() {
197
+ if (this.showingSave) {
198
+ this.closeSave(false);
199
+ return;
200
+ }
201
+ if (this.confirmingQuit) {
202
+ this.confirmingQuit = false;
203
+ this.paint();
204
+ }
205
+ }
206
+ executeSave() {
207
+ if (!this.saveProposal)
208
+ return;
209
+ const result = confirmSave({
210
+ repoRoot: this.repoRoot,
211
+ loaded: this.staging.loadedState,
212
+ staged: this.staging.current,
213
+ target: this.saveTarget,
214
+ globalDir: this.globalDir,
215
+ });
216
+ if (result.kind === "written") {
217
+ this.staging = new FleetStaging(this.staging.current);
218
+ this.closeSave(true, `wrote ${result.path}`);
219
+ }
220
+ else if (result.kind === "refused") {
221
+ this.closeSave(true, `save refused: ${result.reason}`);
222
+ }
223
+ else {
224
+ this.closeSave(false);
225
+ }
226
+ }
227
+ closeSave(notify, message) {
228
+ this.showingSave = false;
229
+ this.saveProposal = null;
230
+ if (notify && message)
231
+ this.notice = message;
232
+ this.paint();
233
+ }
234
+ handleRevert() {
235
+ if (this.isModalOpen)
236
+ return;
237
+ if (this.staging.isDirty) {
238
+ this.staging.revert();
239
+ this.notice = null;
240
+ this.paint();
241
+ }
242
+ else {
243
+ this.notice = "no staged changes";
244
+ this.paint();
77
245
  }
78
246
  }
79
247
  setView(idx) {
@@ -97,6 +265,17 @@ export class StudioApp {
97
265
  if (this.showingHelp) {
98
266
  lines.push(...this.renderHelp());
99
267
  }
268
+ else if (this.showingSave && this.saveProposal) {
269
+ lines.push(...renderDiffModal({
270
+ target: this.saveProposal.target,
271
+ targetPath: this.saveProposal.targetPath,
272
+ diff: this.saveProposal.diff,
273
+ liveRun: this.saveProposal.liveRun,
274
+ }));
275
+ }
276
+ else if (this.confirmingQuit) {
277
+ lines.push(...this.renderQuitConfirm());
278
+ }
100
279
  else {
101
280
  const view = this.views[this.active];
102
281
  lines.push(...view.render({ cols, rows: Math.max(rows - 2, 1) }));
@@ -104,6 +283,7 @@ export class StudioApp {
104
283
  if (this.notice) {
105
284
  lines.push(this.notice);
106
285
  }
286
+ lines.push(this.renderStatusBar());
107
287
  return lines;
108
288
  }
109
289
  renderTabBar() {
@@ -115,6 +295,13 @@ export class StudioApp {
115
295
  }
116
296
  return parts.join(" ");
117
297
  }
298
+ renderStatusBar() {
299
+ if (this.staging.isDirty) {
300
+ const n = this.staging.changeCount;
301
+ return `● ${n} staged change${n === 1 ? "" : "s"}`;
302
+ }
303
+ return "no staged changes";
304
+ }
118
305
  renderHelp() {
119
306
  return [
120
307
  "Key bindings",
@@ -122,9 +309,14 @@ export class StudioApp {
122
309
  "tab cycle views",
123
310
  "? show this help",
124
311
  "esc / q quit",
312
+ "u revert staged changes",
125
313
  "r re-probe (read-only)",
126
- "s save (read-only)",
314
+ "s save",
127
315
  "m mode (read-only)",
128
316
  ];
129
317
  }
318
+ renderQuitConfirm() {
319
+ const n = this.staging.changeCount;
320
+ return [`Quit with ${n} staged change${n === 1 ? "" : "s"}? (y/n)`];
321
+ }
130
322
  }
@@ -0,0 +1,38 @@
1
+ import { type FleetEditable } from "../config/config.js";
2
+ export type SaveTarget = "repo" | "global";
3
+ /** Notice surfaced in the save modal whenever a run lock is live. */
4
+ export declare const RELOAD_GUARD_NOTICE = "reload guard: a live run is active; the running daemon will not see these changes until it reloads";
5
+ export type SaveProposal = {
6
+ target: SaveTarget;
7
+ targetPath: string;
8
+ before: string;
9
+ after: string;
10
+ diff: string;
11
+ liveRun: boolean;
12
+ loadError: string | null;
13
+ };
14
+ export type SaveResult = {
15
+ kind: "written";
16
+ path: string;
17
+ } | {
18
+ kind: "declined";
19
+ } | {
20
+ kind: "refused";
21
+ reason: string;
22
+ };
23
+ /** Build the diff-confirm payload for the current staging delta and chosen target. */
24
+ export declare function buildSaveProposal(opts: {
25
+ repoRoot: string;
26
+ loaded: FleetEditable;
27
+ staged: FleetEditable;
28
+ target: SaveTarget;
29
+ globalDir?: string;
30
+ }): SaveProposal;
31
+ /** Confirm and execute the write for the chosen target. */
32
+ export declare function confirmSave(opts: {
33
+ repoRoot: string;
34
+ loaded: FleetEditable;
35
+ staged: FleetEditable;
36
+ target: SaveTarget;
37
+ globalDir?: string;
38
+ }): SaveResult;