@pellux/goodvibes-tui 0.27.0 → 0.29.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.
- package/CHANGELOG.md +124 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/core/context-usage.ts +53 -0
- package/src/core/conversation.ts +4 -6
- package/src/core/session-recovery.ts +2 -0
- package/src/core/turn-event-wiring.ts +1 -0
- package/src/input/commands/cost-runtime.ts +49 -0
- package/src/input/commands/eval.ts +4 -3
- package/src/input/commands/operator-runtime.ts +5 -1
- package/src/input/commands.ts +2 -0
- package/src/input/handler-feed-routes.ts +60 -26
- package/src/input/handler-feed.ts +17 -8
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-shortcuts.ts +51 -0
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/handler.ts +43 -6
- package/src/input/keybindings.ts +53 -8
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +117 -1
- package/src/main.ts +5 -2
- package/src/panels/agent-inspector-panel.ts +139 -45
- package/src/panels/agent-inspector-shared.ts +147 -3
- package/src/panels/approval-panel.ts +204 -102
- package/src/panels/automation-control-panel.ts +370 -103
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/agent.ts +28 -37
- package/src/panels/builtin/development.ts +40 -32
- package/src/panels/builtin/knowledge.ts +15 -6
- package/src/panels/builtin/operations.ts +178 -117
- package/src/panels/builtin/session.ts +35 -9
- package/src/panels/builtin/shared.ts +98 -6
- package/src/panels/cockpit-panel.ts +253 -75
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +159 -56
- package/src/panels/confirm-state.ts +8 -1
- package/src/panels/control-plane-panel.ts +354 -95
- package/src/panels/cost-tracker-panel.ts +243 -57
- package/src/panels/debug-panel.ts +347 -159
- package/src/panels/diff-panel.ts +257 -82
- package/src/panels/docs-panel.ts +166 -64
- package/src/panels/eval-panel.ts +336 -109
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +238 -159
- package/src/panels/file-preview-panel.ts +141 -59
- package/src/panels/git-panel.ts +360 -191
- package/src/panels/hooks-panel.ts +181 -19
- package/src/panels/incident-review-panel.ts +252 -43
- package/src/panels/index.ts +0 -4
- package/src/panels/intelligence-panel.ts +310 -103
- package/src/panels/knowledge-graph-panel.ts +480 -58
- package/src/panels/local-auth-panel.ts +251 -33
- package/src/panels/marketplace-panel.ts +219 -33
- package/src/panels/memory-panel.ts +83 -81
- package/src/panels/ops-control-panel.ts +211 -32
- package/src/panels/ops-strategy-panel.ts +131 -45
- package/src/panels/orchestration-panel.ts +259 -67
- package/src/panels/panel-list-panel.ts +184 -136
- package/src/panels/panel-manager.ts +120 -13
- package/src/panels/plan-dashboard-panel.ts +507 -74
- package/src/panels/plugins-panel.ts +227 -34
- package/src/panels/policy-panel.ts +264 -55
- package/src/panels/polish-core.ts +162 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +67 -149
- package/src/panels/project-planning-answer-actions.ts +134 -0
- package/src/panels/project-planning-panel.ts +84 -113
- package/src/panels/provider-health-panel.ts +438 -480
- package/src/panels/provider-health-routes.ts +203 -0
- package/src/panels/provider-health-tracker.ts +194 -6
- package/src/panels/provider-health-views.ts +560 -0
- package/src/panels/qr-panel.ts +136 -38
- package/src/panels/remote-panel.ts +123 -38
- package/src/panels/routes-panel.ts +87 -24
- package/src/panels/sandbox-panel.ts +232 -65
- package/src/panels/scrollable-list-panel.ts +143 -145
- package/src/panels/security-panel.ts +204 -52
- package/src/panels/services-panel.ts +174 -76
- package/src/panels/session-browser-panel.ts +101 -6
- package/src/panels/session-maintenance.ts +4 -122
- package/src/panels/settings-sync-panel.ts +346 -68
- package/src/panels/skills-panel.ts +157 -89
- package/src/panels/subscription-panel.ts +105 -34
- package/src/panels/symbol-outline-panel.ts +287 -154
- package/src/panels/system-messages-panel.ts +172 -38
- package/src/panels/tasks-panel.ts +348 -134
- package/src/panels/thinking-panel.ts +66 -32
- package/src/panels/token-budget-panel.ts +233 -46
- package/src/panels/tool-inspector-panel.ts +170 -54
- package/src/panels/types.ts +65 -4
- package/src/panels/work-plan-panel.ts +393 -39
- package/src/panels/worktree-panel.ts +239 -61
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +146 -150
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/conversation-overlays.ts +25 -11
- package/src/renderer/footer-tips.ts +41 -0
- package/src/renderer/fullscreen-primitives.ts +22 -16
- package/src/renderer/help-overlay.ts +91 -14
- package/src/renderer/hint-grammar.ts +52 -0
- package/src/renderer/layout.ts +7 -7
- package/src/renderer/modal-factory.ts +23 -15
- package/src/renderer/model-picker-overlay.ts +30 -11
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/overlay-box.ts +16 -10
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/process-indicator.ts +3 -1
- package/src/renderer/progress.ts +8 -6
- package/src/renderer/search-overlay.ts +27 -6
- package/src/renderer/session-picker-modal.ts +6 -4
- package/src/renderer/settings-modal.ts +70 -10
- package/src/renderer/shell-surface.ts +41 -15
- package/src/renderer/status-glyphs.ts +11 -9
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/theme.ts +60 -3
- package/src/renderer/ui-factory.ts +45 -36
- package/src/renderer/ui-primitives.ts +23 -2
- package/src/runtime/bootstrap-shell.ts +35 -18
- package/src/runtime/diagnostics/panels/index.ts +0 -3
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/shell/ui-openers.ts +14 -22
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/utils/splash-lines.ts +44 -3
- package/src/version.ts +1 -1
- package/src/work-plans/work-plan-store.ts +13 -0
- package/src/panels/agent-logs-panel.ts +0 -666
- package/src/panels/agent-logs-shared.ts +0 -129
- package/src/panels/context-visualizer-panel.ts +0 -214
- package/src/panels/forensics-panel.ts +0 -364
- package/src/panels/panel-picker.ts +0 -106
- package/src/panels/provider-account-snapshot.ts +0 -259
- package/src/panels/provider-accounts-panel.ts +0 -218
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/schedule-panel.ts +0 -342
- package/src/panels/watchers-panel.ts +0 -193
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/runtime/diagnostics/panels/policy.ts +0 -177
|
@@ -1,41 +1,28 @@
|
|
|
1
1
|
import { promises as fsPromises } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
3
|
import type { Line } from '../types/grid.ts';
|
|
4
4
|
import { createEmptyLine } from '../types/grid.ts';
|
|
5
5
|
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
6
6
|
import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
8
|
+
import { FilePreviewPanel } from './file-preview-panel.ts';
|
|
9
|
+
import type { PanelIntegrationContext } from './types.ts';
|
|
8
10
|
import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
|
|
9
|
-
import type
|
|
11
|
+
import { listInstalledEcosystemEntries, type EcosystemCatalogPathOptions, type ShellPathService } from '@/runtime/index.ts';
|
|
10
12
|
import {
|
|
11
13
|
buildPanelLine,
|
|
12
14
|
buildPanelWorkspace,
|
|
13
15
|
DEFAULT_PANEL_PALETTE,
|
|
16
|
+
extendPalette,
|
|
14
17
|
} from './polish.ts';
|
|
15
|
-
import {
|
|
16
|
-
getPanelSearchFocusTransition,
|
|
17
|
-
isPanelSearchCancel,
|
|
18
|
-
} from './search-focus.ts';
|
|
19
18
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
label: '#64748b',
|
|
28
|
-
value: '#e2e8f0',
|
|
29
|
-
dim: '#64748b',
|
|
30
|
-
empty: '#334155',
|
|
31
|
-
selectedFg: '#e2e8f0',
|
|
32
|
-
selectedBg: '#1e3a5f',
|
|
33
|
-
project: '#38bdf8',
|
|
34
|
-
global: '#a78bfa',
|
|
35
|
-
hint: '#475569',
|
|
36
|
-
path: '#94a3b8',
|
|
37
|
-
selectBg: '#1e3a5f',
|
|
38
|
-
} as const;
|
|
20
|
+
// Domain accents only; base chrome (header/headerBg/label/value/dim/empty/
|
|
21
|
+
// selectBg) comes from DEFAULT_PANEL_PALETTE.
|
|
22
|
+
const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
23
|
+
project: '#38bdf8', // project-local skill origin
|
|
24
|
+
global: '#a78bfa', // global skill origin
|
|
25
|
+
} as const);
|
|
39
26
|
|
|
40
27
|
export type SkillOrigin = 'project-local' | 'global' | 'custom';
|
|
41
28
|
|
|
@@ -47,11 +34,20 @@ export interface SkillRecord {
|
|
|
47
34
|
dependencies: string[];
|
|
48
35
|
includes: string[];
|
|
49
36
|
frontmatter: Record<string, string>;
|
|
37
|
+
/**
|
|
38
|
+
* Set when this skill's file lives under an installed ecosystem-marketplace
|
|
39
|
+
* receipt's targetPath — the receipt's provenance summary (e.g. curated
|
|
40
|
+
* source / signature info), so marketplace-installed skills read as such
|
|
41
|
+
* instead of looking identical to hand-authored ones.
|
|
42
|
+
*/
|
|
43
|
+
marketplaceProvenance?: string;
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
export interface SkillsPanelOptions {
|
|
53
47
|
shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
|
|
54
48
|
componentHealthMonitor?: ComponentHealthMonitor;
|
|
49
|
+
/** When provided, installed skill entries are tagged with marketplace provenance. */
|
|
50
|
+
ecosystemPaths?: EcosystemCatalogPathOptions;
|
|
55
51
|
}
|
|
56
52
|
|
|
57
53
|
function parseFrontmatter(content: string): Record<string, string> {
|
|
@@ -133,7 +129,31 @@ async function scanSkillDirectory(root: string, origin: SkillOrigin): Promise<Sk
|
|
|
133
129
|
return records;
|
|
134
130
|
}
|
|
135
131
|
|
|
136
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Tags records whose file lives under an installed ecosystem-marketplace
|
|
134
|
+
* receipt's targetPath with that receipt's provenance summary. Best-effort:
|
|
135
|
+
* a lookup failure (e.g. no ecosystem catalogs configured) leaves records
|
|
136
|
+
* untagged rather than failing skill discovery.
|
|
137
|
+
*/
|
|
138
|
+
function applyMarketplaceProvenance(records: SkillRecord[], ecosystemPaths: EcosystemCatalogPathOptions): SkillRecord[] {
|
|
139
|
+
let receipts: ReturnType<typeof listInstalledEcosystemEntries>;
|
|
140
|
+
try {
|
|
141
|
+
receipts = listInstalledEcosystemEntries('skill', ecosystemPaths);
|
|
142
|
+
} catch {
|
|
143
|
+
return records;
|
|
144
|
+
}
|
|
145
|
+
if (receipts.length === 0) return records;
|
|
146
|
+
return records.map((record) => {
|
|
147
|
+
const receipt = receipts.find((candidate) => record.path === candidate.targetPath || record.path.startsWith(`${candidate.targetPath}${sep}`));
|
|
148
|
+
if (!receipt) return record;
|
|
149
|
+
return { ...record, marketplaceProvenance: receipt.provenanceSummary };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function discoverSkills(
|
|
154
|
+
shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>,
|
|
155
|
+
ecosystemPaths?: EcosystemCatalogPathOptions,
|
|
156
|
+
): Promise<SkillRecord[]> {
|
|
137
157
|
const cwd = shellPaths.workingDirectory;
|
|
138
158
|
const homeDir = shellPaths.homeDirectory;
|
|
139
159
|
const seen = new Set<string>();
|
|
@@ -147,7 +167,9 @@ export async function discoverSkills(shellPaths: Pick<ShellPathService, 'working
|
|
|
147
167
|
}
|
|
148
168
|
}
|
|
149
169
|
|
|
150
|
-
|
|
170
|
+
const tagged = ecosystemPaths ? applyMarketplaceProvenance(records, ecosystemPaths) : records;
|
|
171
|
+
|
|
172
|
+
return tagged.sort((a, b) => {
|
|
151
173
|
const originRank = a.origin === b.origin
|
|
152
174
|
? 0
|
|
153
175
|
: a.origin === 'project-local'
|
|
@@ -227,27 +249,34 @@ function originColor(origin: SkillOrigin): string {
|
|
|
227
249
|
}
|
|
228
250
|
}
|
|
229
251
|
|
|
230
|
-
export class SkillsPanel extends
|
|
252
|
+
export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
|
|
231
253
|
private readonly shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
|
|
232
|
-
|
|
233
|
-
private filterFocused = false;
|
|
254
|
+
private readonly ecosystemPaths: EcosystemCatalogPathOptions | undefined;
|
|
234
255
|
private cached: SkillRecord[] | null = null;
|
|
235
256
|
private cacheDirty = true;
|
|
236
257
|
// I1: confirm state for destructive delete
|
|
237
258
|
private confirm: ConfirmState | null = null;
|
|
238
259
|
private readyPromise: Promise<void> | null = null;
|
|
260
|
+
// Staged pending action consumed by handlePanelIntegrationAction (same
|
|
261
|
+
// pattern as diff-panel.ts's pendingOpenPreview): Enter marks the intent
|
|
262
|
+
// here, the actual PanelManager/preview wiring happens once the
|
|
263
|
+
// integration context is available.
|
|
264
|
+
private pendingOpenPreview = false;
|
|
239
265
|
|
|
240
266
|
public constructor(options: SkillsPanelOptions) {
|
|
241
|
-
super('skills', 'Skills', '
|
|
267
|
+
super('skills', 'Skills', '▩', 'automation-control', options.componentHealthMonitor);
|
|
242
268
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
269
|
+
this.filterEnabled = true; // WO-153: converged modal '/' filter
|
|
270
|
+
this.filterLabel = 'Filter';
|
|
243
271
|
this.shellPaths = options.shellPaths;
|
|
272
|
+
this.ecosystemPaths = options.ecosystemPaths;
|
|
244
273
|
}
|
|
245
274
|
|
|
246
275
|
// -------------------------------------------------------------------------
|
|
247
|
-
//
|
|
276
|
+
// ScrollableListPanel implementation
|
|
248
277
|
// -------------------------------------------------------------------------
|
|
249
278
|
|
|
250
|
-
protected
|
|
279
|
+
protected getItems(): readonly SkillRecord[] {
|
|
251
280
|
return this.cached ?? [];
|
|
252
281
|
}
|
|
253
282
|
|
|
@@ -255,9 +284,9 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
255
284
|
const p = (async () => {
|
|
256
285
|
try {
|
|
257
286
|
await this.withLoading('Scanning skills\u2026', async () => {
|
|
258
|
-
this.cached = await discoverSkills(this.shellPaths);
|
|
287
|
+
this.cached = await discoverSkills(this.shellPaths, this.ecosystemPaths);
|
|
259
288
|
this.cacheDirty = false;
|
|
260
|
-
this.
|
|
289
|
+
this.markDirty();
|
|
261
290
|
});
|
|
262
291
|
} catch (err) {
|
|
263
292
|
this.setError(summarizeError(err));
|
|
@@ -273,14 +302,31 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
273
302
|
return this.readyPromise ?? Promise.resolve();
|
|
274
303
|
}
|
|
275
304
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
305
|
+
/**
|
|
306
|
+
* REAL delete: removes the confirmed skill's markdown file from disk, then
|
|
307
|
+
* rescans so the list reflects the change immediately. Replaces the old
|
|
308
|
+
* setError('Delete via shell: rm …') signpost, which never actually
|
|
309
|
+
* deleted anything.
|
|
310
|
+
*/
|
|
311
|
+
private async _deleteSkill(path: string): Promise<void> {
|
|
312
|
+
try {
|
|
313
|
+
await fsPromises.rm(path);
|
|
314
|
+
this.cacheDirty = true;
|
|
315
|
+
await this._loadSkillsAsync();
|
|
316
|
+
} catch (err) {
|
|
317
|
+
this.setError(summarizeError(err));
|
|
318
|
+
this.markDirty();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** `q` arrives already trimmed + lower-cased from ScrollableListPanel.getVisibleItems(). */
|
|
323
|
+
protected override filterMatches(skill: SkillRecord, q: string): boolean {
|
|
279
324
|
const haystack = [
|
|
280
325
|
skill.name,
|
|
281
326
|
skill.description,
|
|
282
327
|
skill.path,
|
|
283
328
|
skill.origin,
|
|
329
|
+
skill.marketplaceProvenance ?? '',
|
|
284
330
|
skill.dependencies.join(' '),
|
|
285
331
|
skill.includes.join(' '),
|
|
286
332
|
].join(' ').toLowerCase();
|
|
@@ -290,17 +336,22 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
290
336
|
protected renderItem(skill: SkillRecord, index: number, selected: boolean, width: number): Line {
|
|
291
337
|
const bg = selected ? C.selectBg : undefined;
|
|
292
338
|
const dot = skill.origin === 'project-local' ? '\u25c6' : '\u2022';
|
|
339
|
+
// Marketplace-installed skills get a badge glyph; unbadged column stays a
|
|
340
|
+
// single blank cell so rows stay aligned either way.
|
|
341
|
+
const marketplaceBadge = skill.marketplaceProvenance ? '\u2605' : ' ';
|
|
293
342
|
const desc = skill.description || 'No description provided.';
|
|
294
|
-
const descWidth = Math.max(1, width -
|
|
343
|
+
const descWidth = Math.max(1, width - 6 - skill.name.length - 6);
|
|
295
344
|
const descLines = wordWrap(desc, descWidth);
|
|
296
345
|
return buildPanelLine(width, [
|
|
297
|
-
[selected ? '\u25b8' : ' ', C.
|
|
346
|
+
[selected ? '\u25b8' : ' ', C.value, bg],
|
|
298
347
|
[' ', C.dim, bg],
|
|
299
348
|
[dot, originColor(skill.origin), bg],
|
|
300
349
|
[' ', C.dim, bg],
|
|
301
|
-
[
|
|
350
|
+
[marketplaceBadge, C.info, bg],
|
|
351
|
+
[' ', C.dim, bg],
|
|
352
|
+
[skill.name, selected ? C.value : C.value, bg],
|
|
302
353
|
[' ', C.dim, bg],
|
|
303
|
-
[descLines[0] ?? '', selected ? C.
|
|
354
|
+
[descLines[0] ?? '', selected ? C.value : C.dim, bg],
|
|
304
355
|
]);
|
|
305
356
|
}
|
|
306
357
|
|
|
@@ -315,24 +366,66 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
315
366
|
|
|
316
367
|
public override onActivate(): void {
|
|
317
368
|
super.onActivate();
|
|
318
|
-
this.
|
|
319
|
-
this.
|
|
320
|
-
this.filterFocused = false;
|
|
369
|
+
this.filterQuery = '';
|
|
370
|
+
this.filterActive = false;
|
|
321
371
|
this.cacheDirty = true;
|
|
322
372
|
void this._loadSkillsAsync();
|
|
323
373
|
}
|
|
324
374
|
|
|
325
375
|
public override onDestroy(): void {}
|
|
326
376
|
|
|
377
|
+
protected override onSelect(_skill: SkillRecord): void {
|
|
378
|
+
// Enter opens the skill's markdown source in the preview panel — see
|
|
379
|
+
// handlePanelIntegrationAction for the actual PanelManager wiring
|
|
380
|
+
// (needs the integration context, not available here). Selection itself
|
|
381
|
+
// is read back from getVisibleItems() in that hook. onSelect is only
|
|
382
|
+
// invoked by ScrollableListPanel's navigation handler when the filter is
|
|
383
|
+
// not active, so no manual guard is needed here.
|
|
384
|
+
this.pendingOpenPreview = true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Cross-panel integration hook — Enter opens the selected skill's markdown
|
|
389
|
+
* source in the preview panel via the same open/focus bridge DiffPanel
|
|
390
|
+
* uses (src/input/panel-integration-actions.ts), without this panel
|
|
391
|
+
* needing to know about PanelManager pane/focus mechanics beyond what ctx
|
|
392
|
+
* exposes.
|
|
393
|
+
*/
|
|
394
|
+
public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
|
|
395
|
+
if (!this.pendingOpenPreview) return false;
|
|
396
|
+
this.pendingOpenPreview = false;
|
|
397
|
+
const skill = this.getSelectedItem();
|
|
398
|
+
if (!skill) return false;
|
|
399
|
+
|
|
400
|
+
const pm = ctx.panelManager;
|
|
401
|
+
let previewPanel = pm.getPanel('preview');
|
|
402
|
+
if (previewPanel instanceof FilePreviewPanel) {
|
|
403
|
+
const pane = pm.getPaneOf('preview');
|
|
404
|
+
pm.activateById('preview');
|
|
405
|
+
if (pane) pm.focusPane(pane);
|
|
406
|
+
} else {
|
|
407
|
+
const targetPane: 'top' | 'bottom' = pm.isBottomPaneVisible()
|
|
408
|
+
? (pm.getFocusedPane() === 'top' ? 'bottom' : 'top')
|
|
409
|
+
: 'bottom';
|
|
410
|
+
const opened = pm.open('preview', targetPane);
|
|
411
|
+
pm.show();
|
|
412
|
+
pm.focusPane(targetPane);
|
|
413
|
+
previewPanel = opened instanceof FilePreviewPanel ? opened : null;
|
|
414
|
+
}
|
|
415
|
+
if (previewPanel instanceof FilePreviewPanel) {
|
|
416
|
+
previewPanel.openFile(skill.path);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
|
|
327
422
|
public handleInput(key: string): boolean {
|
|
328
423
|
// I1: y/n confirmation dialog for delete
|
|
329
424
|
const confirmResult = handleConfirmInput(this.confirm, key);
|
|
330
425
|
if (confirmResult === 'confirmed') {
|
|
331
426
|
const toDelete = this.confirm!.subject;
|
|
332
427
|
this.confirm = null;
|
|
333
|
-
|
|
334
|
-
// Surface an error directing the user to remove the file manually.
|
|
335
|
-
this.setError(`Delete via shell: rm "${toDelete}"`);
|
|
428
|
+
void this._deleteSkill(toDelete);
|
|
336
429
|
this.markDirty();
|
|
337
430
|
return true;
|
|
338
431
|
}
|
|
@@ -343,38 +436,11 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
343
436
|
}
|
|
344
437
|
if (confirmResult === 'absorbed') return true;
|
|
345
438
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
//
|
|
349
|
-
if (this.
|
|
350
|
-
const
|
|
351
|
-
if (transition === 'focus-list') {
|
|
352
|
-
this.filterFocused = false;
|
|
353
|
-
this.markDirty();
|
|
354
|
-
return true;
|
|
355
|
-
}
|
|
356
|
-
// Escape: also blur filter focus (clear + return to list navigation)
|
|
357
|
-
if (isPanelSearchCancel(key)) {
|
|
358
|
-
this.filterFocused = false;
|
|
359
|
-
// Delegate to super to clear the query. If the query is empty, super
|
|
360
|
-
// returns false and escape propagates to the panel dismissal handler —
|
|
361
|
-
// this is the intentional double-escape UX (blur filter, then close).
|
|
362
|
-
return super.handleInput(key);
|
|
363
|
-
}
|
|
364
|
-
// Delegate backspace/printable to SearchableListPanel.handleInput
|
|
365
|
-
return super.handleInput(key);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
|
|
369
|
-
if (transition === 'focus-search') {
|
|
370
|
-
this.filterFocused = true;
|
|
371
|
-
this.markDirty();
|
|
372
|
-
return true;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// I1: 'd' prompts delete confirmation
|
|
376
|
-
if (key === 'd') {
|
|
377
|
-
const skill = items[this.selectedIndex];
|
|
439
|
+
// I1: 'd' prompts delete confirmation — only outside filter mode, so 'd'
|
|
440
|
+
// remains typeable into the filter query while it is active (WO-153:
|
|
441
|
+
// converged modal '/' filter coexists with single-letter action keys).
|
|
442
|
+
if (!this.filterActive && key === 'd') {
|
|
443
|
+
const skill = this.getSelectedItem();
|
|
378
444
|
if (skill) {
|
|
379
445
|
this.confirm = { subject: skill.path, label: skill.name };
|
|
380
446
|
this.markDirty();
|
|
@@ -382,7 +448,8 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
382
448
|
return true;
|
|
383
449
|
}
|
|
384
450
|
|
|
385
|
-
// Navigation +
|
|
451
|
+
// Navigation + filter: delegate to ScrollableListPanel ('/' enters
|
|
452
|
+
// filter, typing narrows, Esc clears, up/down/g/G/page/enter navigate).
|
|
386
453
|
return super.handleInput(key);
|
|
387
454
|
}
|
|
388
455
|
|
|
@@ -402,27 +469,28 @@ export class SkillsPanel extends SearchableListPanel<SkillRecord> {
|
|
|
402
469
|
return lines.slice(0, height);
|
|
403
470
|
}
|
|
404
471
|
|
|
405
|
-
// Build filter input line (provided by SearchableListPanel base)
|
|
406
|
-
const filterLine = this.buildFilterInputLine(width, 'Filter', this.filterFocused);
|
|
407
|
-
|
|
408
472
|
// Build detail footer for the currently selected skill
|
|
409
|
-
const
|
|
410
|
-
const selected = items[this.selectedIndex];
|
|
473
|
+
const selected = this.getSelectedItem();
|
|
411
474
|
const detailLines: Line[] = [];
|
|
412
475
|
if (selected) {
|
|
413
476
|
detailLines.push(
|
|
414
477
|
buildPanelLine(width, [[' Selected: ', C.label], [selected.name, C.value], [' [', C.dim], [originLabel(selected.origin), originColor(selected.origin)], [']', C.dim]]),
|
|
415
|
-
buildPanelLine(width, [[' Path: ', C.label], [truncatePathDisplay(selected.path, Math.max(1, width - 8)), C.
|
|
478
|
+
buildPanelLine(width, [[' Path: ', C.label], [truncatePathDisplay(selected.path, Math.max(1, width - 8)), C.label]]),
|
|
416
479
|
buildPanelLine(width, [[' Desc: ', C.label], [selected.description || 'No description provided.', C.value]]),
|
|
417
480
|
buildPanelLine(width, [[' Depends: ', C.label], [selected.dependencies.length > 0 ? selected.dependencies.join(', ') : 'none', C.dim]]),
|
|
418
481
|
buildPanelLine(width, [[' Includes: ', C.label], [selected.includes.length > 0 ? selected.includes.join(', ') : 'none', C.dim]]),
|
|
482
|
+
buildPanelLine(width, [
|
|
483
|
+
[' Provenance: ', C.label],
|
|
484
|
+
[selected.marketplaceProvenance ?? 'not installed via marketplace', selected.marketplaceProvenance ? C.info : C.dim],
|
|
485
|
+
]),
|
|
419
486
|
);
|
|
420
487
|
}
|
|
421
|
-
detailLines.push(buildPanelLine(width, [[' Up/Down navigate /
|
|
488
|
+
detailLines.push(buildPanelLine(width, [[' Up/Down navigate / filter Esc clear Backspace edit', C.dim]]));
|
|
489
|
+
detailLines.push(buildPanelLine(width, [[' Enter open in preview d delete (confirm)', C.dim]]));
|
|
422
490
|
|
|
491
|
+
// Filter input line is auto-injected by renderList() (filterEnabled=true).
|
|
423
492
|
const lines = this.renderList(width, height, {
|
|
424
493
|
title: 'Skills - discover project-local and global skill packs',
|
|
425
|
-
header: [filterLine],
|
|
426
494
|
footer: detailLines,
|
|
427
495
|
});
|
|
428
496
|
return lines;
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
|
+
import { fitDisplay } from '../utils/terminal-width.ts';
|
|
4
|
+
import { formatElapsed } from '../utils/format-elapsed.ts';
|
|
3
5
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
|
-
import type { KeyName } from './types.ts';
|
|
6
|
+
import type { KeyName, PanelIntegrationContext } from './types.ts';
|
|
5
7
|
import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
|
|
6
8
|
import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
|
|
7
9
|
import { type ConfirmState, handleConfirmInput } from './confirm-state.ts';
|
|
8
10
|
import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime/ui-service-queries.ts';
|
|
9
11
|
import {
|
|
10
12
|
buildEmptyState,
|
|
11
|
-
|
|
13
|
+
buildKeyboardHints,
|
|
12
14
|
buildKeyValueLine,
|
|
13
15
|
buildPanelListRow,
|
|
14
16
|
buildPanelLine,
|
|
@@ -17,17 +19,9 @@ import {
|
|
|
17
19
|
DEFAULT_PANEL_PALETTE,
|
|
18
20
|
} from './polish.ts';
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
headerBg: '#1e293b',
|
|
24
|
-
dim: '#64748b',
|
|
25
|
-
info: '#38bdf8',
|
|
26
|
-
good: '#22c55e',
|
|
27
|
-
warn: '#f59e0b',
|
|
28
|
-
bad: '#ef4444',
|
|
29
|
-
selectedBg: '#0f172a',
|
|
30
|
-
} as const;
|
|
22
|
+
// Base chrome only — title band, state colors, and text tokens all come
|
|
23
|
+
// straight from DEFAULT_PANEL_PALETTE (WO-002).
|
|
24
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
31
25
|
|
|
32
26
|
interface SubscriptionRow {
|
|
33
27
|
readonly provider: string;
|
|
@@ -55,18 +49,38 @@ function statusColor(status: ReturnType<typeof statusOf>): string {
|
|
|
55
49
|
}
|
|
56
50
|
}
|
|
57
51
|
|
|
52
|
+
// Highlight window for "expiring soon" — mirrors the provider-console
|
|
53
|
+
// convention of flagging tokens inside their last day of validity so the
|
|
54
|
+
// operator can refresh/re-login before the subscription actually lapses.
|
|
55
|
+
const EXPIRING_SOON_MS = 24 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
function isExpiringSoon(subscription: ProviderSubscription | null): boolean {
|
|
58
|
+
if (!subscription?.expiresAt) return false;
|
|
59
|
+
const remaining = subscription.expiresAt - Date.now();
|
|
60
|
+
return remaining > 0 && remaining <= EXPIRING_SOON_MS;
|
|
61
|
+
}
|
|
62
|
+
|
|
58
63
|
export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
59
64
|
private readonly serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
|
|
60
65
|
private readonly subscriptionManager: SubscriptionAccessQuery;
|
|
61
66
|
private rows: SubscriptionRow[] = [];
|
|
62
67
|
/** Pending logout confirmation — uses project-standard ConfirmState contract. */
|
|
63
68
|
private confirm: ConfirmState<string> | null = null;
|
|
69
|
+
// Set by handleInput (Enter on an 'available' row) and consumed on the very
|
|
70
|
+
// next handlePanelIntegrationAction dispatch of that same key — handleInput
|
|
71
|
+
// has no ctx.executeCommand, so the actual login-start dispatch happens via
|
|
72
|
+
// the bridge (remote-panel.ts / token-budget-panel.ts pattern).
|
|
73
|
+
private pendingCommand: { name: string; args: string[] } | null = null;
|
|
74
|
+
// Background posture refresh while the panel is visible, so a login/logout
|
|
75
|
+
// that completes elsewhere (e.g. the browser OAuth handshake finishing)
|
|
76
|
+
// shows up without requiring an explicit 'r'.
|
|
77
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
78
|
|
|
65
79
|
public constructor(
|
|
66
80
|
serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
|
|
67
81
|
subscriptionManager: SubscriptionAccessQuery,
|
|
68
82
|
) {
|
|
69
|
-
super('subscription', 'Subscriptions', 'B', '
|
|
83
|
+
super('subscription', 'Subscriptions', 'B', 'providers');
|
|
70
84
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
71
85
|
this.serviceRegistry = serviceRegistry;
|
|
72
86
|
this.subscriptionManager = subscriptionManager;
|
|
@@ -75,6 +89,28 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
75
89
|
public override onActivate(): void {
|
|
76
90
|
super.onActivate();
|
|
77
91
|
this.refresh();
|
|
92
|
+
if (this.refreshTimer === null) {
|
|
93
|
+
this.refreshTimer = this.registerTimer(setInterval(() => {
|
|
94
|
+
this.refresh();
|
|
95
|
+
this.markDirty();
|
|
96
|
+
}, 5_000));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public override onDeactivate(): void {
|
|
101
|
+
if (this.refreshTimer !== null) {
|
|
102
|
+
this.clearTimer(this.refreshTimer);
|
|
103
|
+
this.refreshTimer = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Consumes the login-start command queued by handleInput's Enter-on-'available' branch. */
|
|
108
|
+
public handlePanelIntegrationAction(_key: string, ctx: PanelIntegrationContext): boolean {
|
|
109
|
+
if (!this.pendingCommand) return false;
|
|
110
|
+
const { name, args } = this.pendingCommand;
|
|
111
|
+
this.pendingCommand = null;
|
|
112
|
+
void ctx.executeCommand?.(name, args);
|
|
113
|
+
return true;
|
|
78
114
|
}
|
|
79
115
|
|
|
80
116
|
protected override getPalette() { return C; }
|
|
@@ -93,12 +129,17 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
93
129
|
|
|
94
130
|
protected renderItem(row: SubscriptionRow, index: number, selected: boolean, width: number): Line {
|
|
95
131
|
const status = statusOf(row);
|
|
132
|
+
// Expiring-soon highlight: the STATUS text stays 'ACTIVE' (the row is
|
|
133
|
+
// still active), but it's colored as a warning and gets a trailing
|
|
134
|
+
// marker so an about-to-lapse token stands out from the list.
|
|
135
|
+
const expiringSoon = isExpiringSoon(row.subscription);
|
|
96
136
|
return buildPanelListRow(width, [
|
|
97
|
-
{ text: row.provider
|
|
98
|
-
{ text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
|
|
137
|
+
{ text: fitDisplay(row.provider, 16), fg: C.value },
|
|
138
|
+
{ text: ` ${status.toUpperCase().padEnd(12)}`, fg: expiringSoon ? C.warn : statusColor(status) },
|
|
99
139
|
{ text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
|
|
100
140
|
{ text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
|
|
101
|
-
|
|
141
|
+
...(expiringSoon ? [{ text: ' expiring soon', fg: C.warn }] : []),
|
|
142
|
+
], C, { selected, selectedBg: C.selectBg });
|
|
102
143
|
}
|
|
103
144
|
|
|
104
145
|
public handleInput(key: KeyName): boolean {
|
|
@@ -120,7 +161,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
120
161
|
if (confirmResult === 'absorbed') return true;
|
|
121
162
|
|
|
122
163
|
if (this.rows.length === 0) return false;
|
|
123
|
-
const selected = this.
|
|
164
|
+
const selected = this.getSelectedItem() ?? null;
|
|
124
165
|
if (key === 'up' || key === 'k') {
|
|
125
166
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
126
167
|
this.confirm = null;
|
|
@@ -134,10 +175,18 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
134
175
|
return true;
|
|
135
176
|
}
|
|
136
177
|
if (key === 'enter' || key === 'return') {
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
178
|
+
if (selected?.subscription) {
|
|
179
|
+
this.confirm = { subject: selected.provider, label: selected.provider };
|
|
180
|
+
this.markDirty();
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
// Enter on an 'available' row starts the real login flow via the
|
|
184
|
+
// bridge instead of leaving it as a printed signpost.
|
|
185
|
+
if (selected && statusOf(selected) === 'available') {
|
|
186
|
+
this.pendingCommand = { name: 'subscription', args: ['login', selected.provider, 'start'] };
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
141
190
|
}
|
|
142
191
|
if (key === 'r') {
|
|
143
192
|
this.refresh();
|
|
@@ -177,9 +226,29 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
177
226
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
|
|
178
227
|
}
|
|
179
228
|
|
|
229
|
+
/** Footer hints that reflect the current view: confirm-pending vs browsing. */
|
|
230
|
+
private buildFooterHint(width: number): Line {
|
|
231
|
+
if (this.confirm) {
|
|
232
|
+
return buildKeyboardHints(width, [
|
|
233
|
+
{ keys: 'y/Enter', label: 'confirm sign out' },
|
|
234
|
+
{ keys: 'n/Esc', label: 'cancel' },
|
|
235
|
+
], C);
|
|
236
|
+
}
|
|
237
|
+
const selected = this.getSelectedItem();
|
|
238
|
+
const hints: Array<{ keys: string; label: string }> = [{ keys: 'Up/Down', label: 'select' }];
|
|
239
|
+
if (selected?.subscription) hints.push({ keys: 'Enter', label: 'sign out' });
|
|
240
|
+
else if (selected?.hasOAuthConfig) hints.push({ keys: 'Enter', label: 'sign in' });
|
|
241
|
+
hints.push({ keys: 'r', label: 'refresh' });
|
|
242
|
+
return buildKeyboardHints(width, hints, C);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// WO-139: refresh() moved out of render() (hooks-panel.ts:109 pattern) — it
|
|
246
|
+
// now runs on onActivate(), on 'r', and on the 5s background timer above,
|
|
247
|
+
// instead of rebuilding the row set (service registry scan + builtin
|
|
248
|
+
// provider list + subscription-manager reads) on every single frame.
|
|
180
249
|
public render(width: number, height: number): Line[] {
|
|
181
|
-
this.refresh();
|
|
182
250
|
this.clampSelection();
|
|
251
|
+
const selected = this.getSelectedItem();
|
|
183
252
|
const intro = 'Review provider login state, subscription-backed routing, and pending browser auth handshakes.';
|
|
184
253
|
|
|
185
254
|
const activeCount = this.rows.filter((row) => row.subscription).length;
|
|
@@ -192,10 +261,9 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
192
261
|
{ label: 'providers', value: String(this.rows.length), valueColor: C.value },
|
|
193
262
|
], C),
|
|
194
263
|
buildKeyValueLine(width, [
|
|
195
|
-
{ label: 'selected', value: (
|
|
196
|
-
{ label: 'status', value:
|
|
264
|
+
{ label: 'selected', value: (selected?.provider ?? 'none'), valueColor: selected ? C.value : C.dim },
|
|
265
|
+
{ label: 'status', value: selected ? statusOf(selected) : 'n/a', valueColor: selected ? statusColor(statusOf(selected)) : C.dim },
|
|
197
266
|
], C),
|
|
198
|
-
buildGuidanceLine(width, '/subscription login <provider> start', 'start or repair browser login for the selected provider route', C),
|
|
199
267
|
];
|
|
200
268
|
|
|
201
269
|
// Empty state: render posture + base empty state
|
|
@@ -213,8 +281,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
213
281
|
intro,
|
|
214
282
|
sections: [{ lines: [...summaryLines, ...emptyLines] }],
|
|
215
283
|
footerLines: [
|
|
216
|
-
|
|
217
|
-
buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
|
|
284
|
+
this.buildFooterHint(width),
|
|
218
285
|
],
|
|
219
286
|
palette: C,
|
|
220
287
|
});
|
|
@@ -222,7 +289,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
222
289
|
return workspace.slice(0, height);
|
|
223
290
|
}
|
|
224
291
|
|
|
225
|
-
const selectedRow =
|
|
292
|
+
const selectedRow = selected;
|
|
226
293
|
const detailRows: Line[] = [];
|
|
227
294
|
if (selectedRow) {
|
|
228
295
|
detailRows.push(buildKeyValueLine(width, [
|
|
@@ -231,12 +298,13 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
231
298
|
{ label: 'oauth config', value: selectedRow.hasOAuthConfig ? 'present' : 'missing', valueColor: selectedRow.hasOAuthConfig ? C.good : C.bad },
|
|
232
299
|
], C));
|
|
233
300
|
if (selectedRow.subscription) {
|
|
301
|
+
const expiringSoon = isExpiringSoon(selectedRow.subscription);
|
|
234
302
|
const expires = selectedRow.subscription.expiresAt
|
|
235
303
|
? new Date(selectedRow.subscription.expiresAt).toISOString()
|
|
236
304
|
: 'n/a';
|
|
237
305
|
detailRows.push(buildKeyValueLine(width, [
|
|
238
306
|
{ label: 'token type', value: selectedRow.subscription.tokenType, valueColor: C.info },
|
|
239
|
-
{ label: 'expires', value: expires, valueColor: C.dim },
|
|
307
|
+
{ label: 'expires', value: expiringSoon ? `${expires} (expiring soon)` : expires, valueColor: expiringSoon ? C.warn : C.dim },
|
|
240
308
|
], C));
|
|
241
309
|
detailRows.push(buildPanelLine(width, [[
|
|
242
310
|
` ${selectedRow.subscription.overrideAmbientApiKeys
|
|
@@ -244,13 +312,17 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
244
312
|
: 'Stored for subscription-backed flows. Ambient API-key resolution remains unchanged.'}`,
|
|
245
313
|
C.dim,
|
|
246
314
|
]]));
|
|
315
|
+
if (expiringSoon) {
|
|
316
|
+
detailRows.push(buildPanelLine(width, [[' Token expires within 24h — sign out and re-login (Enter) to refresh it before it lapses.', C.warn]]));
|
|
317
|
+
}
|
|
247
318
|
if (this.confirm?.subject === selectedRow.provider) {
|
|
248
319
|
detailRows.push(buildPanelLine(width, [[` Sign out ${selectedRow.provider}? Press y or Enter to confirm, n or Esc to cancel.`, C.warn]]));
|
|
249
320
|
}
|
|
250
321
|
} else if (selectedRow.pending) {
|
|
251
|
-
|
|
322
|
+
const age = formatElapsed(Date.now() - selectedRow.pending.createdAt);
|
|
323
|
+
detailRows.push(buildPanelLine(width, [[` Login is pending (started ${age} ago). Finish with /subscription login <provider> finish <code>.`, C.warn]]));
|
|
252
324
|
} else if (selectedRow.hasOAuthConfig) {
|
|
253
|
-
detailRows.push(buildPanelLine(width, [[' Ready for login.
|
|
325
|
+
detailRows.push(buildPanelLine(width, [[' Ready for login. Press Enter to start it here.', C.dim]]));
|
|
254
326
|
} else {
|
|
255
327
|
detailRows.push(buildPanelLine(width, [[' Add a provider-specific OAuth config or enable a built-in subscription provider to use subscription login.', C.bad]]));
|
|
256
328
|
}
|
|
@@ -263,8 +335,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
263
335
|
header: headerLines,
|
|
264
336
|
footer: [
|
|
265
337
|
...detailRows,
|
|
266
|
-
|
|
267
|
-
buildPanelLine(width, [[' Up/Down move Enter sign out selected provider y/Esc confirm/cancel r refresh', C.dim]]),
|
|
338
|
+
this.buildFooterHint(width),
|
|
268
339
|
],
|
|
269
340
|
});
|
|
270
341
|
}
|