@pellux/goodvibes-tui 0.26.0 → 0.28.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.
Files changed (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -4,9 +4,11 @@ import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
4
  import type { UiReadModel, UiRoutesSnapshot } from '../runtime/ui-read-models.ts';
5
5
  import { truncateDisplay } from '../utils/terminal-width.ts';
6
6
  import {
7
+ buildDetailBlock,
7
8
  buildEmptyState,
8
9
  buildGuidanceLine,
9
10
  buildKeyValueLine,
11
+ buildKeyboardHints,
10
12
  buildPanelLine,
11
13
  buildPanelWorkspace,
12
14
  buildStatusPill,
@@ -39,10 +41,20 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
39
41
  public constructor(readModel?: UiReadModel<UiRoutesSnapshot>) {
40
42
  super('routes', 'Routes', 'R', 'monitoring');
41
43
  this.showSelectionGutter = true; // I5: non-color selection affordance
44
+ this.filterEnabled = true;
45
+ this.filterLabel = 'Filter routes';
42
46
  this.readModel = readModel;
43
47
  this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
44
48
  }
45
49
 
50
+ protected override filterMatches(binding: RouteBinding, q: string): boolean {
51
+ return binding.surfaceKind.toLowerCase().includes(q)
52
+ || (binding.title ?? '').toLowerCase().includes(q)
53
+ || binding.externalId.toLowerCase().includes(q)
54
+ || (binding.sessionId ?? '').toLowerCase().includes(q)
55
+ || (binding.runId ?? '').toLowerCase().includes(q);
56
+ }
57
+
46
58
  public override onDestroy(): void {
47
59
  this.unsub?.();
48
60
  }
@@ -127,7 +139,7 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
127
139
  this.clampSelection();
128
140
  const selected = bindings[this.selectedIndex]!;
129
141
 
130
- const footerLines: Line[] = [
142
+ const detailRows: Line[] = [
131
143
  buildPanelLine(width, [
132
144
  [' Binding: ', C.label],
133
145
  [selected.id, C.value],
@@ -159,20 +171,30 @@ export class RoutesPanel extends ScrollableListPanel<RouteBinding> {
159
171
  ];
160
172
 
161
173
  if (surfaceEntries.length > 0) {
162
- footerLines.push(
163
- ...surfaceEntries.slice(0, 6).map(([surface, ids]) => buildPanelLine(width, [
174
+ detailRows.push(
175
+ ...surfaceEntries.slice(0, 4).map(([surface, ids]) => buildPanelLine(width, [
164
176
  [' ', C.label],
165
177
  [surface.padEnd(10), C.info],
166
178
  [` ${String(ids.length)} binding(s)`, C.value],
167
179
  ])),
168
180
  );
169
181
  }
170
- footerLines.push(buildPanelLine(width, [[' Up/Down move through route bindings', C.dim]]));
182
+
183
+ const hints = this.filterActive
184
+ ? [{ keys: 'type', label: 'filter' }, { keys: 'Enter', label: 'apply' }, { keys: 'Esc', label: 'clear' }]
185
+ : [
186
+ { keys: 'Up/Down', label: 'move' },
187
+ { keys: '/communication', label: 'inspect flow' },
188
+ { keys: '/', label: 'filter' },
189
+ ];
171
190
 
172
191
  return this.renderList(width, height, {
173
192
  title: 'Route Bindings',
174
193
  header: headerLines,
175
- footer: footerLines,
194
+ footer: [
195
+ ...buildDetailBlock(width, `Binding · ${selected.surfaceKind}`, detailRows, C),
196
+ buildKeyboardHints(width, hints, C),
197
+ ],
176
198
  });
177
199
  }
178
200
  }
@@ -5,9 +5,11 @@ import { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
5
5
  import { buildSandboxReview, listSandboxPresets, listSandboxProfiles } from '@/runtime/index.ts';
6
6
  import type { SandboxSessionRegistry } from '@/runtime/index.ts';
7
7
  import {
8
+ buildAlignedRow,
8
9
  buildBodyText,
9
10
  buildEmptyState,
10
11
  buildGuidanceLine,
12
+ buildKeyboardHints,
11
13
  buildKeyValueLine,
12
14
  buildPanelLine,
13
15
  buildPanelWorkspace,
@@ -125,7 +127,10 @@ export class SandboxPanel extends BasePanel {
125
127
  buildGuidanceLine(width, '/sandbox wrapper-test <profile>', 'validate the wrapper bridge contract before wiring a real guest transport', C),
126
128
  buildGuidanceLine(width, '/sandbox session run <id> <command> [args...]', 'execute through a tracked sandbox session and capture runtime metadata on the session record', C),
127
129
  buildGuidanceLine(width, 'GV_SANDBOX_WRAPPER_MODE=host-exec', 'validate the wrapper contract on the host before wiring a real guest transport', C),
128
- buildPanelLine(width, [[` Up/Down move Home/End jump focus=profiles+sessions`, C.dim]]),
130
+ buildKeyboardHints(width, [
131
+ { keys: '↑/↓', label: 'select profile/session' },
132
+ { keys: 'Home/End', label: 'jump to first profile / first session' },
133
+ ], C),
129
134
  ];
130
135
 
131
136
  const selectionLines: Line[] = [];
@@ -185,41 +190,71 @@ export class SandboxPanel extends BasePanel {
185
190
  ));
186
191
  } else {
187
192
  for (const session of sessions) {
188
- const bg = selectedSession?.id === session.id ? C.headerBg : undefined;
189
- sessionLines.push(buildPanelLine(width, [
190
- [' ', C.label],
191
- [session.profileId.padEnd(15), C.info, bg],
192
- [session.state.padEnd(10), session.state === 'running' ? C.good : session.state === 'failed' ? C.bad : C.warn, bg],
193
- [(session.shared ? 'shared' : 'dedicated').padEnd(12), C.value, bg],
194
- [String(session.resolvedBackend ?? session.backend).padEnd(8), C.dim, bg],
195
- [` ${(session.startupStatus ?? 'n/a').slice(0, 8).padEnd(8)}`, session.startupStatus === 'verified' ? C.good : session.startupStatus === 'failed' ? C.bad : C.warn, bg],
196
- [` ${String(session.executionCount ?? 0).padStart(3)}x`, C.info, bg],
197
- [` ${session.id.slice(0, Math.max(8, Math.min(14, width - 64)))}`, C.dim, bg],
198
- ]));
193
+ const selectedRow = selectedSession?.id === session.id;
194
+ sessionLines.push(buildAlignedRow(
195
+ width,
196
+ [
197
+ { text: session.profileId, fg: C.info },
198
+ { text: session.state, fg: session.state === 'running' ? C.good : session.state === 'failed' ? C.bad : C.warn },
199
+ { text: session.shared ? 'shared' : 'dedicated', fg: C.value },
200
+ { text: String(session.resolvedBackend ?? session.backend), fg: C.dim },
201
+ { text: session.startupStatus ?? 'n/a', fg: session.startupStatus === 'verified' ? C.good : session.startupStatus === 'failed' ? C.bad : C.warn },
202
+ { text: `${session.executionCount ?? 0}x`, fg: C.info },
203
+ { text: session.id, fg: C.dim },
204
+ ],
205
+ [
206
+ { width: 15 },
207
+ { width: 10 },
208
+ { width: 12 },
209
+ { width: 8 },
210
+ { width: 9 },
211
+ { width: 5, align: 'right' },
212
+ { width: Math.max(8, width - 72) },
213
+ ],
214
+ { selected: selectedRow, selectedBg: C.headerBg },
215
+ ));
199
216
  }
200
217
  }
201
218
 
202
219
  const presetLines: Line[] = [];
203
220
  for (const preset of presets.slice(0, 2)) {
204
- presetLines.push(buildPanelLine(width, [
205
- [' ', C.label],
206
- [preset.id.padEnd(18), C.info],
207
- [preset.config.replIsolation.padEnd(16), C.value],
208
- [preset.config.mcpIsolation.padEnd(16), C.dim],
209
- [preset.config.windowsMode, C.warn],
210
- ]));
221
+ presetLines.push(buildAlignedRow(
222
+ width,
223
+ [
224
+ { text: preset.id, fg: C.info },
225
+ { text: preset.config.replIsolation, fg: C.value },
226
+ { text: preset.config.mcpIsolation, fg: C.dim },
227
+ { text: preset.config.windowsMode, fg: C.warn },
228
+ ],
229
+ [
230
+ { width: 18 },
231
+ { width: 16 },
232
+ { width: 16 },
233
+ { width: Math.max(8, width - 62) },
234
+ ],
235
+ {},
236
+ ));
211
237
  }
212
238
 
213
239
  const profileLines: Line[] = [];
214
240
  for (const profile of profiles) {
215
- const bg = selectedProfile?.id === profile.id ? C.headerBg : undefined;
216
- profileLines.push(buildPanelLine(width, [
217
- [' ', C.label],
218
- [profile.id.padEnd(15), C.info, bg],
219
- [profile.isolation.padEnd(14), C.value, bg],
220
- [profile.kind.padEnd(12), C.dim, bg],
221
- [` vm=${profile.requiresVm ? 'yes' : 'no'}`, profile.requiresVm ? C.good : C.warn, bg],
222
- ]));
241
+ const selectedRow = selectedProfile?.id === profile.id;
242
+ profileLines.push(buildAlignedRow(
243
+ width,
244
+ [
245
+ { text: profile.id, fg: C.info },
246
+ { text: profile.isolation, fg: C.value },
247
+ { text: profile.kind, fg: C.dim },
248
+ { text: `vm=${profile.requiresVm ? 'yes' : 'no'}`, fg: profile.requiresVm ? C.good : C.warn },
249
+ ],
250
+ [
251
+ { width: 15 },
252
+ { width: 14 },
253
+ { width: 12 },
254
+ { width: Math.max(6, width - 53) },
255
+ ],
256
+ { selected: selectedRow, selectedBg: C.headerBg },
257
+ ));
223
258
  }
224
259
  const postureSection: PanelWorkspaceSection = { title: 'Sandbox posture', lines: overviewLines };
225
260
  const selectedSection: PanelWorkspaceSection = { title: selectedProfile ? 'Selected Profile' : 'Selected Session', lines: selectionLines };
@@ -1,13 +1,16 @@
1
1
  import { BasePanel } from './base-panel.ts';
2
2
  import { type Line } from '../types/grid.ts';
3
+ import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
3
4
  import type { AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
4
5
  import type { AutomationJob } from '@pellux/goodvibes-sdk/platform/automation';
5
6
  import type { AutomationRun } from '@pellux/goodvibes-sdk/platform/automation';
6
7
  import type { AutomationScheduleDefinition } from '@pellux/goodvibes-sdk/platform/automation';
7
8
  import {
8
9
  buildEmptyState,
10
+ buildKeyboardHints,
9
11
  buildPanelLine,
10
12
  buildPanelWorkspace,
13
+ extendPalette,
11
14
  resolveScrollablePanelSection,
12
15
  DEFAULT_PANEL_PALETTE,
13
16
  type PanelWorkspaceSection,
@@ -17,12 +20,9 @@ import {
17
20
  // Colors
18
21
  // ---------------------------------------------------------------------------
19
22
 
20
- const C = {
21
- header: '#00d7ff',
22
- sectionHeader: '244',
23
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
23
24
  enabled: '#5fd700',
24
25
  disabled: '#6c6c6c',
25
- selected: '#1c1c1c',
26
26
  selectedFg: '#ffffff',
27
27
  id: '238',
28
28
  cron: '#af87ff',
@@ -32,8 +32,7 @@ const C = {
32
32
  runCount: '#ffaf00',
33
33
  statusRunning: '#5fd700',
34
34
  statusFailed: '#ff5f5f',
35
- hint: '240',
36
- } as const;
35
+ });
37
36
 
38
37
  // ---------------------------------------------------------------------------
39
38
  // View items
@@ -90,11 +89,16 @@ export class SchedulePanel extends BasePanel {
90
89
  private refreshTimerId: ReturnType<typeof setInterval> | null = null;
91
90
  private readonly automationManager: ScheduleAutomationManager;
92
91
 
93
- constructor(automationManager: ScheduleAutomationManager) {
92
+ constructor(automationManager: ScheduleAutomationManager, private readonly requestRender: () => void = () => {}) {
94
93
  super('schedule', 'Schedule', 'Z', 'agent');
95
94
  this.automationManager = automationManager;
96
95
  }
97
96
 
97
+ private _markDirtyAndRender(): void {
98
+ this.markDirty();
99
+ this.requestRender();
100
+ }
101
+
98
102
  // -------------------------------------------------------------------------
99
103
  // Lifecycle
100
104
  // -------------------------------------------------------------------------
@@ -103,12 +107,12 @@ export class SchedulePanel extends BasePanel {
103
107
  super.onActivate();
104
108
  void this.automationManager.start().then(() => {
105
109
  this.rebuild();
106
- this.markDirty();
110
+ this._markDirtyAndRender();
107
111
  });
108
112
  this.rebuild();
109
113
  this.refreshTimerId = this.registerTimer(setInterval(() => {
110
114
  this.rebuild();
111
- this.markDirty();
115
+ this._markDirtyAndRender();
112
116
  }, 5_000));
113
117
  }
114
118
 
@@ -224,14 +228,19 @@ export class SchedulePanel extends BasePanel {
224
228
  lines: buildEmptyState(
225
229
  width,
226
230
  ' No scheduled tasks',
227
- 'Use /schedule add to create a recurring task. Scheduled runs and history will appear here.',
228
- [],
231
+ 'Create a recurring task and its next-run timing, run history, and enablement state will appear here.',
232
+ [
233
+ { command: '/schedule add cron 0 * * * * repo sweep', summary: 'create a recurring cron task' },
234
+ { command: '/schedule list', summary: 'inspect scheduled tasks from the shell' },
235
+ ],
229
236
  DEFAULT_PANEL_PALETTE,
230
237
  ),
231
238
  },
232
239
  ],
233
240
  footerLines: [
234
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
241
+ buildKeyboardHints(width, [
242
+ { keys: '/schedule add', label: 'create a task' },
243
+ ], DEFAULT_PANEL_PALETTE),
235
244
  ],
236
245
  palette: DEFAULT_PANEL_PALETTE,
237
246
  });
@@ -239,6 +248,7 @@ export class SchedulePanel extends BasePanel {
239
248
 
240
249
  const taskItems = this.items.filter((item): item is Extract<ViewItem, { kind: 'task' }> => item.kind === 'task');
241
250
  this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, taskItems.length - 1));
251
+ const dueSoon = tasks.filter((task: AutomationJob) => typeof task.nextRunAt === 'number').length;
242
252
  const summarySection: PanelWorkspaceSection = {
243
253
  title: 'Summary',
244
254
  lines: [
@@ -247,14 +257,28 @@ export class SchedulePanel extends BasePanel {
247
257
  [String(tasks.length), DEFAULT_PANEL_PALETTE.value],
248
258
  [' Enabled ', DEFAULT_PANEL_PALETTE.label],
249
259
  [String(enabled), enabled > 0 ? DEFAULT_PANEL_PALETTE.good : DEFAULT_PANEL_PALETTE.dim],
260
+ [' Paused ', DEFAULT_PANEL_PALETTE.label],
261
+ [String(tasks.length - enabled), tasks.length - enabled > 0 ? DEFAULT_PANEL_PALETTE.warn : DEFAULT_PANEL_PALETTE.dim],
262
+ [' Scheduled ', DEFAULT_PANEL_PALETTE.label],
263
+ [String(dueSoon), dueSoon > 0 ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
250
264
  ]),
251
265
  ],
252
266
  };
267
+ const selectedTask = taskItems[this.selectedIndex]?.task;
268
+ const toggleLabel = selectedTask
269
+ ? (selectedTask.enabled ? 'pause task' : 'enable task')
270
+ : 'toggle';
271
+ const footerLines = [
272
+ buildKeyboardHints(width, [
273
+ { keys: 'Up/Down', label: 'navigate' },
274
+ { keys: 'Space', label: toggleLabel },
275
+ { keys: 'r', label: 'run now' },
276
+ { keys: 'R', label: 'refresh' },
277
+ ], DEFAULT_PANEL_PALETTE),
278
+ ];
253
279
  const scheduledTasksSection = resolveScrollablePanelSection(width, height, {
254
280
  intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
255
- footerLines: [
256
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
257
- ],
281
+ footerLines,
258
282
  palette: DEFAULT_PANEL_PALETTE,
259
283
  beforeSections: [summarySection],
260
284
  section: {
@@ -275,9 +299,7 @@ export class SchedulePanel extends BasePanel {
275
299
  title: ' Schedule',
276
300
  intro: 'Review recurring scheduled tasks, next run timing, recent history, and enablement state.',
277
301
  sections,
278
- footerLines: [
279
- buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Space', DEFAULT_PANEL_PALETTE.info], [' toggle', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' run now', DEFAULT_PANEL_PALETTE.dim], [' R', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
280
- ],
302
+ footerLines,
281
303
  palette: DEFAULT_PANEL_PALETTE,
282
304
  });
283
305
  }
@@ -289,16 +311,16 @@ export class SchedulePanel extends BasePanel {
289
311
  * Row 3: prompt preview [history]
290
312
  */
291
313
  private renderTask(task: AutomationJob, history: AutomationRun[], selected: boolean, width: number): Line[] {
292
- const bg = selected ? C.selected : undefined;
314
+ const bg = selected ? C.selectBg : undefined;
293
315
  const fgBase = selected ? C.selectedFg : undefined;
294
316
 
295
317
  const bullet = task.enabled ? '* ' : 'o ';
296
318
  const bulletFg = task.enabled ? C.enabled : C.disabled;
297
- const nameStr = task.name.length > 28 ? task.name.slice(0, 25) + '...' : task.name.padEnd(28);
319
+ const nameStr = fitDisplay(task.name, 28);
298
320
  const scheduleText = formatSchedule(task.schedule);
299
321
  const row1 = buildPanelLine(width, [
300
322
  [bullet, bulletFg, bg],
301
- [task.id.slice(0, 12), fgBase ?? C.id, bg],
323
+ [fitDisplay(task.id, 12), fgBase ?? C.id, bg],
302
324
  [' ', fgBase ?? C.prompt, bg],
303
325
  [nameStr, fgBase ?? C.prompt, bg],
304
326
  [' ', fgBase ?? C.prompt, bg],
@@ -321,9 +343,7 @@ export class SchedulePanel extends BasePanel {
321
343
 
322
344
  const maxPromptLen = Math.max(20, width - indent.length - 30);
323
345
  const prompt = task.execution.prompt ?? task.description ?? '';
324
- const promptPreview = prompt.length > maxPromptLen
325
- ? prompt.slice(0, maxPromptLen - 1) + '\u2026'
326
- : prompt;
346
+ const promptPreview = truncateDisplay(prompt, maxPromptLen);
327
347
 
328
348
  // Show last 3 run statuses as colored dots
329
349
  const recentRuns = history.slice(-3);
@@ -5,6 +5,7 @@ import type { PanelCategory } from './types.ts';
5
5
  import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
6
6
  import {
7
7
  buildEmptyState,
8
+ buildKeyboardHints,
8
9
  buildPanelWorkspace,
9
10
  buildSearchInputLine,
10
11
  DEFAULT_PANEL_PALETTE,
@@ -56,6 +57,96 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
56
57
  */
57
58
  protected showSelectionGutter = false;
58
59
 
60
+ // -------------------------------------------------------------------------
61
+ // Opt-in inline filter ( / to filter, Esc to clear )
62
+ // -------------------------------------------------------------------------
63
+ // Set `filterEnabled = true` and override `filterMatches()` to give a list
64
+ // panel a `/`-to-filter affordance that coexists with single-letter action
65
+ // keys (filtering is modal: action keys work until you press `/`). A filter
66
+ // input line is auto-rendered at the top of the list.
67
+ protected filterEnabled = false;
68
+ protected filterQuery = '';
69
+ protected filterActive = false;
70
+ /** Short noun shown in the filter hint, e.g. "Filter sessions". */
71
+ protected filterLabel = 'Filter';
72
+
73
+ /** Override to define what the lower-cased filter query matches for an item. */
74
+ protected filterMatches(_item: T, _query: string): boolean {
75
+ return false;
76
+ }
77
+
78
+ /**
79
+ * The list after applying the active filter — used for both display and
80
+ * navigation. Identical to `getItems()` unless a filter is enabled and a
81
+ * non-empty query is set, so existing panels are unaffected.
82
+ */
83
+ protected getVisibleItems(): readonly T[] {
84
+ const all = this.getItems();
85
+ const q = this.filterQuery.trim().toLowerCase();
86
+ if (!this.filterEnabled || !q) return all;
87
+ return all.filter((item) => this.filterMatches(item, q));
88
+ }
89
+
90
+ /**
91
+ * Filter-mode key handling. Returns `true`/`false` when consumed/ignored in
92
+ * filter context, or `null` to fall through to normal navigation.
93
+ */
94
+ private _handleFilterKey(key: string): boolean | null {
95
+ if (!this.filterEnabled) return null;
96
+ if (this.filterActive) {
97
+ if (key === 'escape') {
98
+ this.filterActive = false;
99
+ this.filterQuery = '';
100
+ this.selectedIndex = 0;
101
+ this.needsRender = true;
102
+ return true;
103
+ }
104
+ if (key === 'return' || key === 'enter') {
105
+ this.filterActive = false; // commit; keep the query applied
106
+ this.needsRender = true;
107
+ return true;
108
+ }
109
+ if (key === 'backspace' || key === 'delete') {
110
+ this.filterQuery = this.filterQuery.slice(0, -1);
111
+ this.selectedIndex = 0;
112
+ this.needsRender = true;
113
+ return true;
114
+ }
115
+ // Arrow/paging keys navigate the filtered list — fall through.
116
+ if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown' || key === 'home' || key === 'end') {
117
+ return null;
118
+ }
119
+ // Any printable character (including j/k/g/G) extends the query.
120
+ if (key.length === 1 && key >= ' ') {
121
+ this.filterQuery += key;
122
+ this.selectedIndex = 0;
123
+ this.needsRender = true;
124
+ return true;
125
+ }
126
+ return false;
127
+ }
128
+ if (key === '/') {
129
+ this.filterActive = true;
130
+ this.needsRender = true;
131
+ return true;
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /** The filter input line shown at the top of the list when filtering is enabled. */
137
+ protected buildFilterLine(width: number): Line {
138
+ const palette = this.getPalette();
139
+ const label = this.filterActive ? `[${this.filterLabel}] ` : `${this.filterLabel}: `;
140
+ const value = this.filterActive
141
+ ? `${this.filterQuery}_`
142
+ : this.filterQuery || '(/ to filter)';
143
+ return buildSearchInputLine(width, label, value, palette, {
144
+ active: false,
145
+ bg: this.filterActive ? palette.inputBg : palette.sectionBg,
146
+ valueColor: this.filterActive ? palette.info : (this.filterQuery ? palette.value : palette.dim),
147
+ });
148
+ }
149
+
59
150
  constructor(
60
151
  id: string,
61
152
  name: string,
@@ -143,7 +234,11 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
143
234
  // call this.clearError() at the start of their handler.
144
235
  if (this.lastError !== null) this.clearError();
145
236
 
146
- const items = this.getItems();
237
+ // Opt-in inline filter consumes input before navigation when active.
238
+ const filterResult = this._handleFilterKey(key);
239
+ if (filterResult !== null) return filterResult;
240
+
241
+ const items = this.getVisibleItems();
147
242
  const total = items.length;
148
243
 
149
244
  switch (key) {
@@ -202,7 +297,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
202
297
 
203
298
  handleScroll(deltaRows: number): boolean {
204
299
  if (this.lastError !== null) this.clearError();
205
- const total = this.getItems().length;
300
+ const total = this.getVisibleItems().length;
206
301
  const rows = Math.trunc(deltaRows);
207
302
  if (total === 0 || rows === 0) return false;
208
303
  const next = Math.max(0, Math.min(total - 1, this.selectedIndex + rows));
@@ -221,7 +316,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
221
316
  * Must be called after any data refresh that may shrink the list.
222
317
  */
223
318
  protected clampSelection(): void {
224
- const total = this.getItems().length;
319
+ const total = this.getVisibleItems().length;
225
320
  if (total === 0) {
226
321
  this.selectedIndex = 0;
227
322
  } else {
@@ -253,6 +348,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
253
348
  options: {
254
349
  readonly header?: readonly Line[];
255
350
  readonly footer?: readonly Line[];
351
+ readonly hints?: ReadonlyArray<{ keys: string; label: string }>;
256
352
  readonly emptyMessage?: string;
257
353
  readonly title?: string;
258
354
  readonly spinnerFrame?: number;
@@ -260,19 +356,32 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
260
356
  ): Line[] {
261
357
  this.needsRender = false;
262
358
  const palette = this.getPalette();
263
- const items = this.getItems();
359
+ const items = this.getVisibleItems();
264
360
  const title = options.title ?? this.name;
265
361
 
362
+ // Auto-inject the filter input line at the top of the header when filtering
363
+ // is enabled, so every filterable panel gets the same affordance for free.
364
+ const baseHeader = options.header ? [...options.header as Line[]] : [];
365
+ const header: Line[] | undefined = this.filterEnabled
366
+ ? [this.buildFilterLine(width), ...baseHeader]
367
+ : (options.header ? baseHeader : undefined);
368
+
369
+ // Standardized keyboard-hints footer row (rendered below any explicit footer).
370
+ const hintsLine = options.hints && options.hints.length > 0
371
+ ? buildKeyboardHints(width, options.hints, palette)
372
+ : null;
373
+
266
374
  // I2: inject error line into footer when present
267
375
  const errorLine = this.renderErrorLine(width);
268
376
  const baseFooter = options.footer ? [...options.footer as Line[]] : [];
377
+ if (hintsLine) baseFooter.push(hintsLine);
269
378
  const effectiveFooter: Line[] = errorLine ? [errorLine, ...baseFooter] : baseFooter;
270
379
 
271
380
  // I3: if loading, show spinner in place of normal content
272
381
  const spinnerLine = this.renderLoadingLine(width, options.spinnerFrame ?? 0);
273
382
  if (spinnerLine) {
274
383
  const loadingSection = { lines: [spinnerLine] };
275
- const headerSection = options.header ? [{ lines: options.header as Line[] }] : [];
384
+ const headerSection = header ? [{ lines: header }] : [];
276
385
  const lines = buildPanelWorkspace(width, height, {
277
386
  title,
278
387
  sections: [...headerSection, loadingSection],
@@ -306,17 +415,22 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
306
415
 
307
416
  // Empty state
308
417
  if (scrollableLines.length === 0) {
418
+ // Distinguish "filter hides everything" from "genuinely empty".
419
+ const filteredToEmpty = this.filterEnabled && this.filterQuery.trim() !== '' && this.getItems().length > 0;
420
+ const emptyMessage = filteredToEmpty
421
+ ? ` No matches for "${this.filterQuery.trim()}"`
422
+ : (options.emptyMessage ?? this.getEmptyStateMessage());
309
423
  const emptyLines = buildEmptyState(
310
424
  width,
311
- options.emptyMessage ?? this.getEmptyStateMessage(),
312
- '',
313
- this.getEmptyStateActions(),
425
+ emptyMessage,
426
+ filteredToEmpty ? 'Press Esc to clear the filter.' : '',
427
+ filteredToEmpty ? [] : this.getEmptyStateActions(),
314
428
  palette,
315
429
  );
316
430
  const lines = buildPanelWorkspace(width, height, {
317
431
  title,
318
432
  sections: [
319
- ...(options.header ? [{ lines: options.header as Line[] }] : []),
433
+ ...(header ? [{ lines: header }] : []),
320
434
  { lines: emptyLines },
321
435
  ...(effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : []),
322
436
  ],
@@ -327,7 +441,7 @@ export abstract class ScrollableListPanel<T> extends BasePanel {
327
441
  }
328
442
 
329
443
  // Resolve scrollable section (updates scrollStart)
330
- const beforeSections = options.header ? [{ lines: options.header as Line[] }] : [];
444
+ const beforeSections = header ? [{ lines: header }] : [];
331
445
  const afterSections = effectiveFooter.length > 0 ? [{ lines: effectiveFooter }] : [];
332
446
 
333
447
  const resolved = resolveScrollablePanelSection(width, height, {