@north-light/crouter 0.3.158 → 0.3.159

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.
@@ -35,6 +35,25 @@ const scheduleSource = {
35
35
  return { ok: true, data: crons };
36
36
  },
37
37
  };
38
+ /** Trailing log lines carried with every job row: enough to fill the expanded
39
+ * card, so watching a job needs no second surface. */
40
+ const LOG_TAIL_LINES = 14;
41
+ /** Live background bash jobs for the inspected node. Non-blocking, exactly like
42
+ * the schedule: a node with no jobs dir (or a remote target) degrades this one
43
+ * section, never the whole view. */
44
+ const jobsSource = {
45
+ id: 'jobs',
46
+ request: (target) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'node', 'bash', 'list', '--node', target, '--tail', String(LOG_TAIL_LINES)] }),
47
+ parse: (raw) => {
48
+ const result = parseJson(raw, 'node bash list', false);
49
+ if (!result.ok)
50
+ return result;
51
+ const jobs = result.data['jobs'];
52
+ if (!Array.isArray(jobs))
53
+ return { ok: false, error: error('parse', 'Jobs unavailable', 'The job list did not contain job rows.', false) };
54
+ return { ok: true, data: jobs };
55
+ },
56
+ };
38
57
  const cancelCommand = {
39
58
  id: 'cancel',
40
59
  request: (id) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'cron', 'cancel', id] }),
@@ -45,8 +64,50 @@ const cancelCommand = {
45
64
  return { ok: true, data: { canceled: String(result.data['canceled'] ?? '') } };
46
65
  },
47
66
  };
67
+ const killJobCommand = {
68
+ id: 'kill-job',
69
+ request: ({ target, job }) => ({ kind: 'exec', bin: 'crtr', args: ['--json', 'node', 'bash', 'kill', job, '--node', target] }),
70
+ parse: (raw) => {
71
+ const result = parseJson(raw, 'node bash kill', false);
72
+ if (!result.ok)
73
+ return result;
74
+ return { ok: true, data: { job: String(result.data['job_id'] ?? '') } };
75
+ },
76
+ };
77
+ /** Open the selected job's log in its own tmux window, following like `tail -f`.
78
+ * A new window rather than a nested popup: tmux allows one popup per client and
79
+ * the Inspector is usually already in one — and a window is where you actually
80
+ * want a log you intend to keep watching after the Inspector closes. */
81
+ const openLogCommand = {
82
+ id: 'open-log',
83
+ request: ({ path, title }) => ({
84
+ kind: 'exec',
85
+ bin: 'tmux',
86
+ args: ['new-window', '-n', title, `less +F -- ${quote(path)} 2>/dev/null || tail -f -- ${quote(path)}`],
87
+ }),
88
+ parse: (raw) => {
89
+ if (!raw.ok || (raw.exitCode ?? 0) !== 0) {
90
+ return { ok: false, error: error('no_tmux', 'Could not open the log', raw.stderr || 'tmux new-window failed — the Inspector must be running inside tmux.', false) };
91
+ }
92
+ return { ok: true, data: {} };
93
+ },
94
+ };
95
+ function quote(text) {
96
+ return `'${text.split("'").join(`'\\''`)}'`;
97
+ }
98
+ const SECTION_ORDER = ['overview', 'schedule', 'jobs', 'raw'];
48
99
  function section(value) {
49
- return value === 'schedule' || value === 'raw' ? value : 'overview';
100
+ return value === 'schedule' || value === 'jobs' || value === 'raw' ? value : 'overview';
101
+ }
102
+ /** The section-aware status line: what is pending, counted for what you see. */
103
+ function countsFor(state) {
104
+ const crons = `${scheduledItems(state.schedule.data ?? []).length} cron(s) armed`;
105
+ const jobs = `${(state.jobs.data ?? []).length} background job(s) running`;
106
+ if (state.section === 'schedule')
107
+ return crons;
108
+ if (state.section === 'jobs')
109
+ return jobs;
110
+ return `${crons} · ${jobs}`;
50
111
  }
51
112
  function reconcile(state, next) {
52
113
  const items = scheduledItems(next);
@@ -58,21 +119,30 @@ function reconcile(state, next) {
58
119
  return { ...state, schedule: { data: next, error: null, fetchedAt: Date.now() }, selectedCronId: replacement?.cron.cron_id ?? null, expanded: false, pendingCancel: null };
59
120
  }
60
121
  export const inspectorCore = {
61
- manifest: { id: 'inspector', title: 'Inspector', description: 'Node overview and scheduled crons', refreshMs: 5000 },
122
+ manifest: { id: 'inspector', title: 'Inspector', description: 'Node overview, scheduled crons, and live background bash jobs', refreshMs: 5000 },
62
123
  init(options) {
63
124
  return {
64
125
  target: options['target'] ?? '', section: section(options['section']),
65
126
  dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 },
66
- selectedCronId: null, expanded: false, pendingCancel: null,
67
- scroll: { overview: 0, schedule: 0, raw: 0 },
127
+ jobs: { data: null, error: null, fetchedAt: 0 },
128
+ selectedCronId: null, selectedJobId: null, expanded: false, pendingCancel: null,
129
+ scroll: { overview: 0, schedule: 0, jobs: 0, raw: 0 },
68
130
  };
69
131
  },
70
- sources: { dossier: dossierSource, schedule: scheduleSource },
71
- commands: { cancel: cancelCommand },
132
+ sources: { dossier: dossierSource, schedule: scheduleSource, jobs: jobsSource },
133
+ commands: { cancel: cancelCommand, killJob: killJobCommand, openLog: openLogCommand },
72
134
  intents: {
73
135
  async refresh(ctx) {
74
136
  ctx.signal.setStatus('Refreshing…');
75
- const dossier = await ctx.resolve(dossierSource, ctx.state.target);
137
+ let note;
138
+ // All three reads are independent subprocesses, so they run TOGETHER: the
139
+ // host drops keystrokes while an intent is in flight, and the auto-refresh
140
+ // fires every 5s — resolving them in series would make the view feel deaf.
141
+ const [dossier, schedule, jobs] = await Promise.all([
142
+ ctx.resolve(dossierSource, ctx.state.target),
143
+ ctx.resolve(scheduleSource, ctx.state.target),
144
+ ctx.resolve(jobsSource, ctx.state.target),
145
+ ]);
76
146
  if (!dossier.ok) {
77
147
  ctx.set((state) => ({ ...state, dossier: { ...state.dossier, error: dossier.error } }));
78
148
  ctx.signal.setBanner(`${dossier.error.display.headline}: ${dossier.error.display.explanation}`, 'error');
@@ -80,7 +150,6 @@ export const inspectorCore = {
80
150
  return;
81
151
  }
82
152
  ctx.set((state) => ({ ...state, dossier: { data: dossier.data, error: null, fetchedAt: Date.now() } }));
83
- const schedule = await ctx.resolve(scheduleSource, ctx.state.target);
84
153
  if (!schedule.ok) {
85
154
  ctx.set((state) => ({ ...state, schedule: { ...state.schedule, error: schedule.error } }));
86
155
  ctx.signal.setBanner(schedule.error.display.explanation, 'error');
@@ -89,37 +158,90 @@ export const inspectorCore = {
89
158
  const before = ctx.state.selectedCronId;
90
159
  ctx.set((state) => reconcile(state, schedule.data));
91
160
  if (before !== null && !schedule.data.some((cron) => cron.cron_id === before))
92
- ctx.signal.setStatus('Selected cron disappeared; moved to the next item.');
93
- else
94
- ctx.signal.setStatus(`${schedule.data.length} cron(s) armed`);
95
- ctx.signal.clearBanner();
161
+ note = 'Selected cron disappeared; moved to the next item.';
162
+ // Clear ONLY a banner this source raised. A background refresh must not
163
+ // wipe the user's own "press y to confirm" prompt or an action result.
164
+ if (ctx.state.schedule.error !== null)
165
+ ctx.signal.clearBanner();
166
+ }
167
+ if (!jobs.ok)
168
+ ctx.set((state) => ({ ...state, jobs: { ...state.jobs, error: jobs.error } }));
169
+ else {
170
+ const gone = ctx.state.selectedJobId !== null && !jobs.data.some((job) => job.job_id === ctx.state.selectedJobId);
171
+ ctx.set((state) => reconcileJobs(state, jobs.data));
172
+ if (gone)
173
+ note = 'The selected job finished; moved to the next one.';
96
174
  }
175
+ // One status line, always about the section you are looking at: a count of
176
+ // crons in schedule, of jobs in jobs, of both in the whole-node sections.
177
+ ctx.signal.setStatus(note ?? countsFor(ctx.state));
97
178
  const node = record(ctx.state.dossier.data?.['node']);
98
179
  ctx.signal.setSubtitle(`${node['name'] ?? ctx.state.target} · ${ctx.state.section}`);
99
180
  },
100
- sectionNext(ctx) { const order = ['overview', 'schedule', 'raw']; const next = order[(order.indexOf(ctx.state.section) + 1) % order.length]; ctx.set((state) => ({ ...state, section: next })); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
101
- sectionPrev(ctx) { const order = ['overview', 'schedule', 'raw']; const next = order[(order.indexOf(ctx.state.section) + order.length - 1) % order.length]; ctx.set((state) => ({ ...state, section: next })); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
181
+ sectionNext(ctx) { const order = SECTION_ORDER; const next = order[(order.indexOf(ctx.state.section) + 1) % order.length]; ctx.set((state) => ({ ...state, section: next, pendingCancel: null })); ctx.signal.clearBanner(); ctx.signal.setStatus(countsFor(ctx.state)); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
182
+ sectionPrev(ctx) { const order = SECTION_ORDER; const next = order[(order.indexOf(ctx.state.section) + order.length - 1) % order.length]; ctx.set((state) => ({ ...state, section: next, pendingCancel: null })); ctx.signal.clearBanner(); ctx.signal.setStatus(countsFor(ctx.state)); ctx.signal.setSubtitle(`${ctx.state.target} · ${next}`); },
102
183
  down(ctx) { ctx.set((state) => move(state, 1)); },
103
184
  up(ctx) { ctx.set((state) => move(state, -1)); },
104
185
  pageDown(ctx) { ctx.set((state) => ({ ...state, scroll: { ...state.scroll, [state.section]: state.scroll[state.section] + 8 } })); },
105
186
  pageUp(ctx) { ctx.set((state) => ({ ...state, scroll: { ...state.scroll, [state.section]: Math.max(0, state.scroll[state.section] - 8) } })); },
106
- toggleDetail(ctx) { if (ctx.state.selectedCronId !== null)
107
- ctx.set((state) => ({ ...state, expanded: !state.expanded })); },
108
- cancelItem(ctx) { if (ctx.state.selectedCronId === null)
109
- return; ctx.set((state) => ({ ...state, pendingCancel: state.selectedCronId })); ctx.signal.setBanner(`Cancel ${ctx.state.selectedCronId}? Press y to confirm.`, 'action'); },
187
+ toggleDetail(ctx) {
188
+ if (ctx.state.section === 'jobs' ? ctx.state.selectedJobId !== null : ctx.state.selectedCronId !== null) {
189
+ ctx.set((state) => ({ ...state, expanded: !state.expanded }));
190
+ }
191
+ },
192
+ cancelItem(ctx) {
193
+ const pending = ctx.state.section === 'jobs'
194
+ ? selectedJob(ctx.state) && { kind: 'job', id: selectedJob(ctx.state).job_id, label: commandHead(selectedJob(ctx.state).command) }
195
+ : ctx.state.selectedCronId !== null && { kind: 'cron', id: ctx.state.selectedCronId, label: ctx.state.selectedCronId };
196
+ if (!pending)
197
+ return;
198
+ if (pending.kind === 'job' && selectedJob(ctx.state)?.pgid === null) {
199
+ ctx.signal.setBanner('This job predates recorded process groups — crtr cannot stop it. Open its log and stop it by hand.', 'error');
200
+ return;
201
+ }
202
+ ctx.set((state) => ({ ...state, pendingCancel: pending }));
203
+ ctx.signal.setBanner(pending.kind === 'job'
204
+ ? `Stop job “${pending.label}”? It dies now and the node is told. Press y to confirm.`
205
+ : `Cancel cron ${pending.label}? Press y to confirm.`, 'action');
206
+ },
207
+ async openLog(ctx) {
208
+ const job = selectedJob(ctx.state);
209
+ if (job === undefined)
210
+ return;
211
+ ctx.signal.setStatus('Opening the job log…');
212
+ const result = await ctx.execute(openLogCommand, { path: job.log_path, title: `job · ${commandHead(job.command, 18)}` });
213
+ if (!result.ok) {
214
+ ctx.signal.setBanner(result.error.display.explanation, 'error');
215
+ return;
216
+ }
217
+ ctx.signal.setBanner('Log opened in a new tmux window, following live. Quit the Inspector to read it.', 'info');
218
+ },
110
219
  async confirm(ctx) {
111
- const id = ctx.state.pendingCancel;
112
- if (id === null)
220
+ const pending = ctx.state.pendingCancel;
221
+ if (pending === null)
113
222
  return;
223
+ if (pending.kind === 'job') {
224
+ ctx.signal.setStatus('Stopping the job…');
225
+ const killed = await ctx.execute(killJobCommand, { target: ctx.state.target, job: pending.id });
226
+ ctx.set((state) => ({ ...state, pendingCancel: null }));
227
+ if (!killed.ok) {
228
+ ctx.signal.setBanner(killed.error.display.explanation, 'error');
229
+ return;
230
+ }
231
+ await ctx.dispatch('refresh');
232
+ const stillRunning = (ctx.state.jobs.data ?? []).some((job) => job.job_id === pending.id);
233
+ ctx.signal.setBanner(stillRunning ? 'The job is still running; refresh and retry.' : 'Job stopped — the node was told it was canceled.', stillRunning ? 'error' : 'info');
234
+ return;
235
+ }
114
236
  ctx.signal.setStatus('Canceling cron…');
115
- const result = await ctx.execute(cancelCommand, id);
237
+ const result = await ctx.execute(cancelCommand, pending.id);
116
238
  ctx.set((state) => ({ ...state, pendingCancel: null }));
117
239
  if (!result.ok) {
118
240
  ctx.signal.setBanner(result.error.display.explanation, 'error');
119
241
  return;
120
242
  }
121
243
  await ctx.dispatch('refresh');
122
- const stillArmed = (ctx.state.schedule.data ?? []).some((cron) => cron.cron_id === id);
244
+ const stillArmed = (ctx.state.schedule.data ?? []).some((cron) => cron.cron_id === pending.id);
123
245
  ctx.signal.setBanner(stillArmed ? 'Cron remains armed; refresh and retry.' : 'Cron canceled.', stillArmed ? 'error' : 'info');
124
246
  },
125
247
  dismiss(ctx) { ctx.set((state) => ({ ...state, pendingCancel: null })); ctx.signal.clearBanner(); },
@@ -128,14 +250,41 @@ export const inspectorCore = {
128
250
  const next = cron?.anchor_node ?? cron?.created_by;
129
251
  if (!next)
130
252
  return;
131
- ctx.set((state) => ({ ...state, target: next, selectedCronId: null, expanded: false, pendingCancel: null, dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 } }));
253
+ ctx.set((state) => ({ ...state, target: next, selectedCronId: null, expanded: false, pendingCancel: null, dossier: { data: null, error: null, fetchedAt: 0 }, schedule: { data: null, error: null, fetchedAt: 0 }, jobs: { data: null, error: null, fetchedAt: 0 } }));
132
254
  void ctx.dispatch('refresh');
133
255
  },
134
256
  quit(ctx) { ctx.signal.quit(); },
135
257
  },
136
258
  };
137
259
  function selectedItem(state) { return scheduledItems(state.schedule.data ?? []).find((item) => item.cron.cron_id === state.selectedCronId); }
260
+ export function selectedJob(state) {
261
+ return (state.jobs.data ?? []).find((job) => job.job_id === state.selectedJobId);
262
+ }
263
+ /** First line of a command, for banners and window titles. */
264
+ function commandHead(command, cap = 40) {
265
+ const line = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
266
+ return line.length > cap ? `${line.slice(0, cap - 1)}…` : line;
267
+ }
268
+ /** Keep the job cursor on the same job across refreshes; when it finished, land
269
+ * on whatever took its place rather than losing the cursor entirely. */
270
+ function reconcileJobs(state, next) {
271
+ const fetched = { data: next, error: null, fetchedAt: Date.now() };
272
+ if (state.selectedJobId !== null && next.some((job) => job.job_id === state.selectedJobId))
273
+ return { ...state, jobs: fetched };
274
+ const previousIndex = (state.jobs.data ?? []).findIndex((job) => job.job_id === state.selectedJobId);
275
+ const replacement = next[Math.min(Math.max(0, previousIndex), Math.max(0, next.length - 1))];
276
+ const pendingCancel = state.pendingCancel?.kind === 'job' ? null : state.pendingCancel;
277
+ return { ...state, jobs: fetched, selectedJobId: replacement?.job_id ?? null, expanded: false, pendingCancel };
278
+ }
138
279
  function move(state, delta) {
280
+ if (state.section === 'jobs') {
281
+ const jobs = state.jobs.data ?? [];
282
+ if (jobs.length === 0)
283
+ return state;
284
+ const current = Math.max(0, jobs.findIndex((job) => job.job_id === state.selectedJobId));
285
+ const next = Math.max(0, Math.min(jobs.length - 1, current + delta));
286
+ return { ...state, selectedJobId: jobs[next].job_id, expanded: false, pendingCancel: null };
287
+ }
139
288
  if (state.section !== 'schedule')
140
289
  return { ...state, scroll: { ...state.scroll, [state.section]: Math.max(0, state.scroll[state.section] + delta) } };
141
290
  const items = scheduledItems(state.schedule.data ?? []);
@@ -1,5 +1,17 @@
1
1
  import type { CronDTO } from '../../api/dto/crons.js';
2
- export type InspectorSection = 'overview' | 'schedule' | 'raw';
2
+ export type InspectorSection = 'overview' | 'schedule' | 'jobs' | 'raw';
3
+ /** One live background bash job, as `crtr --json node bash list` reports it. */
4
+ export interface BashJobRow {
5
+ job_id: string;
6
+ command: string;
7
+ started_at: string;
8
+ elapsed_ms: number;
9
+ log_path: string;
10
+ /** Supervisor process group — null for a job started before crtr persisted
11
+ * one, which is exactly the set of jobs the Inspector cannot stop. */
12
+ pgid: number | null;
13
+ log_tail: string[];
14
+ }
3
15
  export interface ScheduledItem {
4
16
  cron: CronDTO;
5
17
  nextAt: string;
@@ -14,6 +26,8 @@ export declare const ICON: {
14
26
  readonly artifacts: string;
15
27
  readonly paths: string;
16
28
  readonly cron: string;
29
+ readonly job: string;
30
+ readonly log: string;
17
31
  readonly section: string;
18
32
  readonly raw: string;
19
33
  readonly empty: string;
@@ -38,6 +52,21 @@ export declare function statusVisual(status: string): {
38
52
  tone: Tone;
39
53
  };
40
54
  export declare function scheduledItems(crons: readonly CronDTO[]): ScheduledItem[];
55
+ /** Elapsed duration in the shape every bash-handoff surface uses: `12s`,
56
+ * `3m 40s`, `1h 04m`. Duplicated from core/bash-jobs.ts on purpose — a view
57
+ * module stays a pure read and never pulls the node:fs job scanner in. */
58
+ export declare function elapsed(ms: number): string;
59
+ /** How a running job's age reads at a glance: an ordered ramp, not decoration.
60
+ * Fresh work is green, a job past 15m is amber, past an hour is red — the same
61
+ * good/warn/bad progression the node status glyphs use. */
62
+ export declare function jobTone(elapsedMs: number): Tone;
63
+ /** Milliseconds a job has been running, live from `started_at`. */
64
+ export declare function jobElapsedMs(job: BashJobRow, now?: number): number;
65
+ /** Live elapsed time for a job row — recomputed from `started_at` so the number
66
+ * keeps ticking between the view's 5s refreshes; `elapsed_ms` is the fallback. */
67
+ export declare function jobElapsed(job: BashJobRow, now?: number): string;
68
+ /** First line of a command, whitespace-collapsed and capped for a one-row cell. */
69
+ export declare function commandCell(command: string, cap?: number): string;
41
70
  export declare function relativeTime(iso: string | null, now?: number): string;
42
71
  /** How long ago an ISO timestamp was, e.g. `3d ago`. `—` when absent. */
43
72
  export declare function ago(iso: unknown, now?: number): string;
@@ -13,6 +13,8 @@ export const ICON = {
13
13
  artifacts: cp(0xf1da), // history/archive — what it left behind
14
14
  paths: cp(0xf0f6), // file-text — on-disk locations
15
15
  cron: cp(0xf017), // clock — one armed cron
16
+ job: cp(0xf013), // cog — one live background bash job
17
+ log: cp(0xf120), // terminal — the job's log stream
16
18
  section: cp(0xf192), // dot-circle — the active tab marker
17
19
  raw: cp(0xf121), // code — the raw record
18
20
  empty: cp(0xf1b3), // cubes — nothing armed
@@ -42,6 +44,43 @@ export function scheduledItems(crons) {
42
44
  return a.cron.cron_id.localeCompare(b.cron.cron_id);
43
45
  });
44
46
  }
47
+ /** Elapsed duration in the shape every bash-handoff surface uses: `12s`,
48
+ * `3m 40s`, `1h 04m`. Duplicated from core/bash-jobs.ts on purpose — a view
49
+ * module stays a pure read and never pulls the node:fs job scanner in. */
50
+ export function elapsed(ms) {
51
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
52
+ if (totalSeconds < 60)
53
+ return `${totalSeconds}s`;
54
+ const totalMinutes = Math.floor(totalSeconds / 60);
55
+ if (totalMinutes < 60)
56
+ return `${totalMinutes}m ${String(totalSeconds % 60).padStart(2, '0')}s`;
57
+ return `${Math.floor(totalMinutes / 60)}h ${String(totalMinutes % 60).padStart(2, '0')}m`;
58
+ }
59
+ /** How a running job's age reads at a glance: an ordered ramp, not decoration.
60
+ * Fresh work is green, a job past 15m is amber, past an hour is red — the same
61
+ * good/warn/bad progression the node status glyphs use. */
62
+ export function jobTone(elapsedMs) {
63
+ if (elapsedMs >= 60 * 60_000)
64
+ return 'bad';
65
+ if (elapsedMs >= 15 * 60_000)
66
+ return 'warn';
67
+ return 'good';
68
+ }
69
+ /** Milliseconds a job has been running, live from `started_at`. */
70
+ export function jobElapsedMs(job, now = Date.now()) {
71
+ const at = Date.parse(job.started_at);
72
+ return Number.isFinite(at) ? now - at : job.elapsed_ms;
73
+ }
74
+ /** Live elapsed time for a job row — recomputed from `started_at` so the number
75
+ * keeps ticking between the view's 5s refreshes; `elapsed_ms` is the fallback. */
76
+ export function jobElapsed(job, now = Date.now()) {
77
+ return elapsed(jobElapsedMs(job, now));
78
+ }
79
+ /** First line of a command, whitespace-collapsed and capped for a one-row cell. */
80
+ export function commandCell(command, cap = 60) {
81
+ const oneLine = (command.split('\n')[0] ?? '').replace(/\s+/g, ' ').trim();
82
+ return oneLine.length > cap ? `${oneLine.slice(0, cap - 1)}…` : oneLine;
83
+ }
45
84
  export function relativeTime(iso, now = Date.now()) {
46
85
  if (iso === null)
47
86
  return '—';
@@ -1,4 +1,4 @@
1
- import { overviewGroups, relativeTime, scheduledItems, value } from './model.js';
1
+ import { jobElapsed, overviewGroups, relativeTime, scheduledItems, value } from './model.js';
2
2
  /** The piped path: same information and grouping as the TUI, minus the glyphs
3
3
  * and hue a pipe cannot carry. */
4
4
  export const inspectorText = {
@@ -27,6 +27,19 @@ export const inspectorText = {
27
27
  const cron = item.cron;
28
28
  lines.push('', ` ${cron.name} · ${item.cadence} · ${relativeTime(item.nextAt)}`, ` id ${cron.cron_id}`, ` next ${item.nextAt}`, ` state ${value(cron.state)}`, ` output ${cron.on_output}${cron.sink ? ` → ${cron.sink}` : ''}`, ` expires ${value(cron.expires_at)}`);
29
29
  }
30
+ lines.push('', 'BACKGROUND BASH JOBS');
31
+ if (state.jobs.error) {
32
+ lines.push(` ${state.jobs.error.display.explanation}`, ` ${state.jobs.error.display.nextStep}`);
33
+ return lines.join('\n');
34
+ }
35
+ const jobs = state.jobs.data ?? [];
36
+ if (jobs.length === 0)
37
+ lines.push(' nothing running');
38
+ for (const job of jobs) {
39
+ lines.push('', ` ${job.command.split('\n')[0]} · ${jobElapsed(job)}`, ` id ${job.job_id}`, ` started ${job.started_at}`, ` log ${job.log_path}`);
40
+ for (const line of job.log_tail.slice(-5))
41
+ lines.push(` | ${line}`);
42
+ }
30
43
  return lines.join('\n');
31
44
  },
32
45
  };
@@ -1,4 +1,4 @@
1
- import { ICON, overviewGroups, relativeTime, scheduledItems, shortPath, value, } from './model.js';
1
+ import { ICON, commandCell, jobElapsed, jobElapsedMs, jobTone, overviewGroups, relativeTime, scheduledItems, shortPath, value, } from './model.js';
2
2
  // ── Visual grammar ───────────────────────────────────────────────────────────
3
3
  //
4
4
  // Three primitives carry the whole screen: a TAB STRIP (where am I / where can
@@ -21,6 +21,7 @@ const HEAD_STYLE = { fg: '36', bold: true };
21
21
  const SECTIONS = [
22
22
  { id: 'overview', label: 'overview' },
23
23
  { id: 'schedule', label: 'schedule' },
24
+ { id: 'jobs', label: 'jobs' },
24
25
  { id: 'raw', label: 'raw' },
25
26
  ];
26
27
  /** `label` padded into the dim label column, then the value. */
@@ -50,7 +51,9 @@ function tabStrip(state, width) {
50
51
  const active = section.id === state.section;
51
52
  const badge = section.id === 'schedule'
52
53
  ? ` ${scheduledItems(state.schedule.data ?? []).length}`
53
- : '';
54
+ : section.id === 'jobs'
55
+ ? ` ${(state.jobs.data ?? []).length}`
56
+ : '';
54
57
  spans.push({
55
58
  text: active ? `${ICON.section} ${section.label}${badge}` : `${section.label}${badge}`,
56
59
  style: active ? { fg: '36', bold: true } : { dim: true },
@@ -147,6 +150,52 @@ function cronItem(item, expanded) {
147
150
  lines,
148
151
  };
149
152
  }
153
+ /** One live background job as a list item. Collapsed it shows the single thing
154
+ * a watcher wants — the newest log line — under the command and its running
155
+ * time; expanding adds the job's identity and a live tail of its log, so the
156
+ * Inspector answers "what is it doing right now" without a second surface. */
157
+ function jobItem(job, expanded, width) {
158
+ const tone = TONE[jobTone(jobElapsedMs(job))];
159
+ const last = job.log_tail[job.log_tail.length - 1];
160
+ const lines = [];
161
+ if (!expanded) {
162
+ lines.push([{ text: `${INDENT}${INDENT}${last === undefined ? 'no output yet' : logCell(last, width - 6)}`, style: { dim: true } }]);
163
+ }
164
+ else {
165
+ for (const [label, text] of [['job', job.job_id], ['started', job.started_at], ['log', shortPath(job.log_path)], ['watch', `tail -f ${shortPath(job.log_path)}`]]) {
166
+ lines.push([{ text: `${INDENT}${INDENT}${label.padEnd(LABEL_COL)}`, style: LABEL_STYLE }, { text, style: label === 'watch' ? { fg: '36' } : undefined }]);
167
+ }
168
+ lines.push([]);
169
+ lines.push(groupHeader(ICON.log, `last ${job.log_tail.length} lines`, width - 2));
170
+ if (job.log_tail.length === 0)
171
+ lines.push([{ text: `${INDENT}${INDENT}no output yet`, style: { dim: true } }]);
172
+ for (const line of job.log_tail)
173
+ lines.push([{ text: `${INDENT}${INDENT}${logCell(line, width - 6)}`, style: { dim: true } }]);
174
+ }
175
+ lines.push([]); // one blank row between jobs — each item reads as its own block
176
+ return {
177
+ spans: [
178
+ { text: ` ${ICON.job} `, style: tone },
179
+ { text: commandCell(job.command, Math.max(20, width - 26)), style: { bold: true } },
180
+ ],
181
+ right: [{ text: `${jobElapsed(job)} `, style: tone }],
182
+ lines,
183
+ };
184
+ }
185
+ /** A log line, tabs expanded and clipped to the card — never wrapped, so one
186
+ * runaway line cannot push the rest of the tail off the screen. */
187
+ function logCell(line, width) {
188
+ const flat = line.replace(/\t/g, ' ').replace(/[\u0000-\u0008\u000b-\u001f]/g, '');
189
+ const cap = Math.max(10, width);
190
+ return flat.length > cap ? `${flat.slice(0, cap - 1)}…` : flat;
191
+ }
192
+ function jobsEmptyRows() {
193
+ return [
194
+ [{ text: ` ${ICON.empty} nothing running`, style: { dim: true } }],
195
+ [],
196
+ [{ text: `${INDENT}This node has no bash commands running in the background.`, style: { dim: true } }],
197
+ ];
198
+ }
150
199
  function scheduleEmptyRows() {
151
200
  return [
152
201
  [{ text: ` ${ICON.empty} nothing armed`, style: { dim: true } }],
@@ -185,6 +234,23 @@ export const inspectorTui = {
185
234
  paint(state, rawRows(state), draw, content);
186
235
  return;
187
236
  }
237
+ if (state.section === 'jobs') {
238
+ if (state.jobs.error) {
239
+ const d = state.jobs.error.display;
240
+ paint(state, errorRows(d.headline, d.explanation, d.nextStep), draw, content);
241
+ return;
242
+ }
243
+ const jobs = state.jobs.data ?? [];
244
+ if (jobs.length === 0) {
245
+ paint(state, jobsEmptyRows(), draw, content);
246
+ return;
247
+ }
248
+ draw.spans(content.row, content.col, tabStrip(state, content.width), content.width);
249
+ const cursor = Math.max(0, jobs.findIndex((job) => job.job_id === state.selectedJobId));
250
+ const rows = jobs.map((job, index) => jobItem(job, index === cursor && state.expanded, content.width));
251
+ draw.list({ ...content, row: content.row + 2, height: Math.max(0, content.height - 2) }, rows, cursor, state.scroll.jobs);
252
+ return;
253
+ }
188
254
  if (state.schedule.error) {
189
255
  const d = state.schedule.error.display;
190
256
  paint(state, errorRows(d.headline, d.explanation, d.nextStep), draw, content);
@@ -209,8 +275,10 @@ export const inspectorTui = {
209
275
  { bindingId: 'crtr.view.inspector.section-prev', intent: 'sectionPrev' },
210
276
  { bindingId: 'crtr.view.inspector.page-down', intent: 'pageDown' },
211
277
  { bindingId: 'crtr.view.inspector.page-up', intent: 'pageUp' },
212
- { bindingId: 'crtr.view.inspector.toggle-detail', intent: 'toggleDetail', when: (state) => state.section === 'schedule' && state.selectedCronId !== null, hint: { label: 'details' } },
278
+ { bindingId: 'crtr.view.inspector.toggle-detail', intent: 'toggleDetail', when: (state) => (state.section === 'schedule' && state.selectedCronId !== null) || (state.section === 'jobs' && state.selectedJobId !== null), hint: { label: 'details' } },
213
279
  { bindingId: 'crtr.view.inspector.cancel-item', intent: 'cancelItem', when: (state) => state.section === 'schedule' && state.selectedCronId !== null, hint: { label: 'cancel' } },
280
+ { bindingId: 'crtr.view.inspector.cancel-item', intent: 'cancelItem', when: (state) => state.section === 'jobs' && state.selectedJobId !== null, hint: { label: 'stop job' } },
281
+ { bindingId: 'crtr.view.inspector.open-log', intent: 'openLog', when: (state) => state.section === 'jobs' && state.selectedJobId !== null, hint: { label: 'open log' } },
214
282
  { bindingId: 'crtr.view.inspector.confirm', intent: 'confirm', when: (state) => state.pendingCancel !== null, hint: { label: 'confirm' } },
215
283
  { bindingId: 'crtr.view.inspector.dismiss', intent: 'dismiss', hint: { label: 'dismiss' } },
216
284
  { bindingId: 'crtr.view.inspector.retarget', intent: 'retarget', when: (state) => state.section === 'schedule' && state.selectedCronId !== null, hint: { label: 'retarget' } },
@@ -31,7 +31,7 @@ const REQUIRED_IDS = [
31
31
  ...ids('crtr.view.linkedin', ['down', 'up', 'open', 'reply', 'react', 'refresh', 'quit', 'submit-reply', 'cancel', 'reaction-previous', 'reaction-next', 'submit-reaction']),
32
32
  ...ids('crtr.view.prompt-review', ['down', 'up', 'compare-previous', 'compare-next', 'toggle-mode', 'expand', 'export', 'refresh', 'quit']),
33
33
  ...ids('crtr.view.workspace-sidebar', ['down', 'up', 'open', 'refresh', 'quit']),
34
- ...ids('crtr.view.inspector', ['down', 'up', 'section-next', 'section-prev', 'page-down', 'page-up', 'toggle-detail', 'cancel-item', 'confirm', 'dismiss', 'retarget', 'refresh', 'quit']),
34
+ ...ids('crtr.view.inspector', ['down', 'up', 'section-next', 'section-prev', 'page-down', 'page-up', 'toggle-detail', 'cancel-item', 'open-log', 'confirm', 'dismiss', 'retarget', 'refresh', 'quit']),
35
35
  ...ids('crtr.view-picker', ['down', 'up', 'run', 'quit']),
36
36
  ...ids('crtr.browse', ['quit', 'down', 'up', 'first', 'last', 'expand-or-child', 'collapse-or-parent', 'toggle-fold', 'tab-next', 'tab-previous', 'tab.1', 'tab.2', 'tab.3', 'tab.4', 'tab.5', 'close-subtree', 'confirm-close', 'sort', 'scope', 'profile', 'residents', 'preview', 'search', 'revive-now', 'focus', 'focus-background']),
37
37
  ...ids('crtr.browse.search', ['cancel', 'commit', 'delete', 'quit']),
@@ -8,8 +8,8 @@ type ProfileMenuAction = 'up' | 'down' | 'select' | 'accept-default' | 'abort' |
8
8
  type SetupAction = 'cancel' | 'tab-next' | 'tab-previous' | 'open' | 'list-down' | 'list-up' | 'toggle' | 'details' | 'advance' | 'cell-previous' | 'cell-next';
9
9
  type SetupKeybindingAction = 'edit' | 'commit-edit' | 'cancel-edit' | 'unbind' | 'reset-row' | 'reset-all' | 'save' | 'discard' | 'page-up' | 'page-down' | 'filter' | 'filter-delete' | 'filter-clear';
10
10
  type SetupModelsAction = 'open' | 'commit' | 'cancel' | 'scope-next';
11
- export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous' | 'command.inspect' | 'file-review'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view.inspector.${'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
12
- export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.fork">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.background-bash">, BindingDefinition<"crtr.tmux.menu.file-review">, BindingDefinition<"crtr.tmux.menu.pin">, BindingDefinition<"crtr.tmux.menu.show">, BindingDefinition<"crtr.tmux.menu.metadata">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.copy-id">, BindingDefinition<"crtr.tmux.menu.reload-viewer">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.attach.command.inspect">, BindingDefinition<"crtr.attach.file-review">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.revive">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view.inspector.down"> | BindingDefinition<"crtr.view.inspector.up"> | BindingDefinition<"crtr.view.inspector.section-next"> | BindingDefinition<"crtr.view.inspector.section-prev"> | BindingDefinition<"crtr.view.inspector.page-down"> | BindingDefinition<"crtr.view.inspector.page-up"> | BindingDefinition<"crtr.view.inspector.toggle-detail"> | BindingDefinition<"crtr.view.inspector.cancel-item"> | BindingDefinition<"crtr.view.inspector.confirm"> | BindingDefinition<"crtr.view.inspector.dismiss"> | BindingDefinition<"crtr.view.inspector.retarget"> | BindingDefinition<"crtr.view.inspector.refresh"> | BindingDefinition<"crtr.view.inspector.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.9" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.6">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
11
+ export type BindingId = `crtr.tmux.menu.${TmuxMenuAction}` | `crtr.tmux.menu.issue.${'idea' | 'bug'}` | `crtr.tmux.node.${'next' | 'previous'}` | `crtr.tmux.view.${'next' | 'previous'}` | 'crtr.tmux.inbox.toggle' | `crtr.attach.${'detach' | 'clear-or-detach' | 'graph.toggle' | 'model-ladder.next' | 'model-ladder.previous' | 'command.inspect' | 'file-review'}` | 'crtr.mode.cycle' | `crtr.graph.${GraphAction}` | `crtr.graph.confirm.${'accept' | 'cancel'}` | `crtr.view.host.${'force-quit' | 'editor.backspace'}` | `crtr.view.canvas.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.chat.${'submit' | 'contextual-cancel' | 'reconnect' | 'quit'}` | `crtr.view.git-pr.${'down' | 'up' | 'refresh' | 'quit'}` | `crtr.view.inbox.${InboxAction}` | `crtr.view.linkedin.${LinkedinAction}` | `crtr.view.prompt-review.${'down' | 'up' | 'compare-previous' | 'compare-next' | 'toggle-mode' | 'expand' | 'export' | 'refresh' | 'quit'}` | `crtr.view.workspace-sidebar.${'down' | 'up' | 'open' | 'refresh' | 'quit'}` | `crtr.view.inspector.${'down' | 'up' | 'section-next' | 'section-prev' | 'page-down' | 'page-up' | 'toggle-detail' | 'cancel-item' | 'open-log' | 'confirm' | 'dismiss' | 'retarget' | 'refresh' | 'quit'}` | `crtr.view-picker.${'down' | 'up' | 'run' | 'quit'}` | `crtr.browse.${BrowseAction}` | `crtr.browse.search.${'cancel' | 'commit' | 'delete' | 'quit'}` | `crtr.profile.menu.${ProfileMenuAction}` | `crtr.profile.search.${'up' | 'down' | 'pick' | 'back' | 'delete' | 'abort'}` | `crtr.setup.${SetupAction}` | `crtr.setup.confirm.${'accept' | 'back'}` | `crtr.setup.keybindings.${SetupKeybindingAction}` | `crtr.setup.models.${SetupModelsAction}`;
12
+ export declare const BINDING_CATALOG: readonly [BindingDefinition<"crtr.tmux.menu.open">, BindingDefinition<"crtr.tmux.menu.promote">, BindingDefinition<"crtr.tmux.menu.resume">, BindingDefinition<"crtr.tmux.menu.fork">, BindingDefinition<"crtr.tmux.menu.demote">, BindingDefinition<"crtr.tmux.menu.detach">, BindingDefinition<"crtr.tmux.menu.close-subtree">, BindingDefinition<"crtr.tmux.menu.background-bash">, BindingDefinition<"crtr.tmux.menu.file-review">, BindingDefinition<"crtr.tmux.menu.pin">, BindingDefinition<"crtr.tmux.menu.show">, BindingDefinition<"crtr.tmux.menu.metadata">, BindingDefinition<"crtr.tmux.menu.context">, BindingDefinition<"crtr.tmux.menu.providers">, BindingDefinition<"crtr.tmux.menu.graph">, BindingDefinition<"crtr.tmux.menu.focus-manager">, BindingDefinition<"crtr.tmux.menu.copy-id">, BindingDefinition<"crtr.tmux.menu.reload-viewer">, BindingDefinition<"crtr.tmux.menu.issues">, BindingDefinition<"crtr.tmux.menu.issue.idea">, BindingDefinition<"crtr.tmux.menu.issue.bug">, BindingDefinition<"crtr.tmux.node.next">, BindingDefinition<"crtr.tmux.node.previous">, BindingDefinition<"crtr.tmux.inbox.toggle">, BindingDefinition<"crtr.tmux.view.next">, BindingDefinition<"crtr.tmux.view.previous">, BindingDefinition<"crtr.attach.detach">, BindingDefinition<"crtr.attach.clear-or-detach">, BindingDefinition<"crtr.attach.graph.toggle">, BindingDefinition<"crtr.attach.model-ladder.next">, BindingDefinition<"crtr.attach.model-ladder.previous">, BindingDefinition<"crtr.attach.command.inspect">, BindingDefinition<"crtr.attach.file-review">, BindingDefinition<"crtr.mode.cycle">, BindingDefinition<"crtr.graph.down">, BindingDefinition<"crtr.graph.up">, BindingDefinition<"crtr.graph.first">, BindingDefinition<"crtr.graph.last">, BindingDefinition<"crtr.graph.collapse-or-manager">, BindingDefinition<"crtr.graph.expand-or-child">, BindingDefinition<"crtr.graph.focus">, BindingDefinition<"crtr.graph.revive">, BindingDefinition<"crtr.graph.focus-manager">, BindingDefinition<"crtr.graph.close-subtree">, BindingDefinition<"crtr.graph.dismiss">, BindingDefinition<"crtr.graph.confirm.accept">, BindingDefinition<"crtr.graph.confirm.cancel">, BindingDefinition<"crtr.view.host.force-quit">, BindingDefinition<"crtr.view.host.editor.backspace">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<"crtr.view.chat.submit">, BindingDefinition<"crtr.view.chat.contextual-cancel">, BindingDefinition<"crtr.view.chat.reconnect">, BindingDefinition<"crtr.view.chat.quit">, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, BindingDefinition<BindingId>, ...(BindingDefinition<BindingId> | BindingDefinition<"crtr.view.prompt-review.down"> | BindingDefinition<"crtr.view.prompt-review.up"> | BindingDefinition<"crtr.view.prompt-review.compare-previous"> | BindingDefinition<"crtr.view.prompt-review.compare-next"> | BindingDefinition<"crtr.view.prompt-review.toggle-mode"> | BindingDefinition<"crtr.view.prompt-review.expand"> | BindingDefinition<"crtr.view.prompt-review.export"> | BindingDefinition<"crtr.view.prompt-review.refresh"> | BindingDefinition<"crtr.view.prompt-review.quit"> | BindingDefinition<"crtr.view.workspace-sidebar.down"> | BindingDefinition<"crtr.view.workspace-sidebar.up"> | BindingDefinition<"crtr.view.workspace-sidebar.open"> | BindingDefinition<"crtr.view.workspace-sidebar.refresh"> | BindingDefinition<"crtr.view.workspace-sidebar.quit"> | BindingDefinition<"crtr.view.inspector.down"> | BindingDefinition<"crtr.view.inspector.up"> | BindingDefinition<"crtr.view.inspector.section-next"> | BindingDefinition<"crtr.view.inspector.section-prev"> | BindingDefinition<"crtr.view.inspector.page-down"> | BindingDefinition<"crtr.view.inspector.page-up"> | BindingDefinition<"crtr.view.inspector.toggle-detail"> | BindingDefinition<"crtr.view.inspector.cancel-item"> | BindingDefinition<"crtr.view.inspector.open-log"> | BindingDefinition<"crtr.view.inspector.confirm"> | BindingDefinition<"crtr.view.inspector.dismiss"> | BindingDefinition<"crtr.view.inspector.retarget"> | BindingDefinition<"crtr.view.inspector.refresh"> | BindingDefinition<"crtr.view.inspector.quit"> | BindingDefinition<"crtr.view-picker.down"> | BindingDefinition<"crtr.view-picker.up"> | BindingDefinition<"crtr.view-picker.run"> | BindingDefinition<"crtr.view-picker.quit"> | BindingDefinition<"crtr.browse.quit"> | BindingDefinition<"crtr.browse.down"> | BindingDefinition<"crtr.browse.up"> | BindingDefinition<"crtr.browse.first"> | BindingDefinition<"crtr.browse.last"> | BindingDefinition<"crtr.browse.expand-or-child"> | BindingDefinition<"crtr.browse.collapse-or-parent"> | BindingDefinition<"crtr.browse.toggle-fold"> | BindingDefinition<"crtr.browse.tab-next"> | BindingDefinition<"crtr.browse.tab-previous"> | BindingDefinition<"crtr.browse.tab.2" | "crtr.browse.tab.1" | "crtr.browse.tab.3" | "crtr.browse.tab.4" | "crtr.browse.tab.5"> | BindingDefinition<"crtr.browse.close-subtree"> | BindingDefinition<"crtr.browse.confirm-close"> | BindingDefinition<"crtr.browse.sort"> | BindingDefinition<"crtr.browse.scope"> | BindingDefinition<"crtr.browse.profile"> | BindingDefinition<"crtr.browse.residents"> | BindingDefinition<"crtr.browse.preview"> | BindingDefinition<"crtr.browse.search"> | BindingDefinition<"crtr.browse.revive-now"> | BindingDefinition<"crtr.browse.focus"> | BindingDefinition<"crtr.browse.focus-background"> | BindingDefinition<"crtr.browse.search.cancel"> | BindingDefinition<"crtr.browse.search.commit"> | BindingDefinition<"crtr.browse.search.delete"> | BindingDefinition<"crtr.browse.search.quit"> | BindingDefinition<"crtr.profile.menu.up"> | BindingDefinition<"crtr.profile.menu.down"> | BindingDefinition<"crtr.profile.menu.select"> | BindingDefinition<"crtr.profile.menu.accept-default"> | BindingDefinition<"crtr.profile.menu.abort"> | BindingDefinition<"crtr.profile.menu.slot.2" | "crtr.profile.menu.slot.1" | "crtr.profile.menu.slot.3" | "crtr.profile.menu.slot.4" | "crtr.profile.menu.slot.5" | "crtr.profile.menu.slot.9" | "crtr.profile.menu.slot.8" | "crtr.profile.menu.slot.7" | "crtr.profile.menu.slot.6">)[], BindingDefinition<"crtr.profile.menu.create">, BindingDefinition<"crtr.profile.menu.search-existing">, BindingDefinition<"crtr.profile.menu.use-root">, BindingDefinition<"crtr.profile.menu.set-default">, BindingDefinition<"crtr.profile.menu.add-directory">, BindingDefinition<"crtr.profile.menu.unpin">, BindingDefinition<"crtr.profile.search.up">, BindingDefinition<"crtr.profile.search.down">, BindingDefinition<"crtr.profile.search.pick">, BindingDefinition<"crtr.profile.search.back">, BindingDefinition<"crtr.profile.search.delete">, BindingDefinition<"crtr.profile.search.abort">, BindingDefinition<"crtr.setup.cancel">, BindingDefinition<"crtr.setup.tab-next">, BindingDefinition<"crtr.setup.tab-previous">, BindingDefinition<"crtr.setup.open">, BindingDefinition<"crtr.setup.list-down">, BindingDefinition<"crtr.setup.list-up">, BindingDefinition<"crtr.setup.toggle">, BindingDefinition<"crtr.setup.details">, BindingDefinition<"crtr.setup.advance">, BindingDefinition<"crtr.setup.confirm.accept">, BindingDefinition<"crtr.setup.confirm.back">, BindingDefinition<"crtr.setup.keybindings.edit">, BindingDefinition<"crtr.setup.keybindings.commit-edit">, BindingDefinition<"crtr.setup.keybindings.cancel-edit">, BindingDefinition<"crtr.setup.keybindings.unbind">, BindingDefinition<"crtr.setup.keybindings.reset-row">, BindingDefinition<"crtr.setup.keybindings.reset-all">, BindingDefinition<"crtr.setup.keybindings.save">, BindingDefinition<"crtr.setup.keybindings.discard">, BindingDefinition<"crtr.setup.keybindings.page-up">, BindingDefinition<"crtr.setup.keybindings.page-down">, BindingDefinition<"crtr.setup.keybindings.filter">, BindingDefinition<"crtr.setup.keybindings.filter-delete">, BindingDefinition<"crtr.setup.keybindings.filter-clear">, BindingDefinition<"crtr.setup.cell-previous">, BindingDefinition<"crtr.setup.cell-next">, BindingDefinition<"crtr.setup.models.open">, BindingDefinition<"crtr.setup.models.scope-next">, BindingDefinition<"crtr.setup.models.commit">, BindingDefinition<"crtr.setup.models.cancel">];
13
13
  export declare const BINDING_IDS: ReadonlySet<string>;
14
14
  export declare const SAFETY_REQUIREMENTS: readonly SafetyRequirement[];
15
15
  export {};
@@ -118,8 +118,9 @@ export const BINDING_CATALOG = Object.freeze([
118
118
  b('crtr.view.inspector.section-prev', 'Views', 'Previous Inspector section', 'Move to the previous Inspector section.', ['shift+tab'], 'terminal', ['view.inspector']),
119
119
  b('crtr.view.inspector.page-down', 'Views', 'Page down Inspector', 'Scroll the Inspector detail body down.', ['ctrl+d', 'pagedown'], 'terminal', ['view.inspector']),
120
120
  b('crtr.view.inspector.page-up', 'Views', 'Page up Inspector', 'Scroll the Inspector detail body up.', ['ctrl+u', 'pageup'], 'terminal', ['view.inspector']),
121
- b('crtr.view.inspector.toggle-detail', 'Views', 'Toggle cron detail', 'Expand or collapse the selected cron.', ['enter'], 'terminal', ['view.inspector']),
122
- b('crtr.view.inspector.cancel-item', 'Views', 'Cancel cron', 'Arm cancellation for the selected cron.', ['x'], 'terminal', ['view.inspector']),
121
+ b('crtr.view.inspector.toggle-detail', 'Views', 'Toggle item detail', 'Expand or collapse the selected cron or background job.', ['enter'], 'terminal', ['view.inspector']),
122
+ b('crtr.view.inspector.cancel-item', 'Views', 'Cancel selected item', 'Arm cancellation for the selected cron, or stopping for the selected background job.', ['x'], 'terminal', ['view.inspector']),
123
+ b('crtr.view.inspector.open-log', 'Views', 'Open job log', 'Follow the selected background job’s log in a new tmux window.', ['o'], 'terminal', ['view.inspector']),
123
124
  b('crtr.view.inspector.confirm', 'Views', 'Confirm Inspector action', 'Confirm the armed Inspector action.', ['y'], 'terminal', ['view.inspector']),
124
125
  b('crtr.view.inspector.dismiss', 'Views', 'Dismiss Inspector action', 'Dismiss an Inspector confirmation or banner.', ['escape'], 'terminal', ['view.inspector'], 'dismiss-modal'),
125
126
  b('crtr.view.inspector.retarget', 'Views', 'Retarget Inspector', 'Inspect the selected cron anchor or creator node.', ['t'], 'terminal', ['view.inspector']),