@pellux/goodvibes-tui 0.27.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.
- package/CHANGELOG.md +23 -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/eval.ts +4 -3
- package/src/input/handler-feed-routes.ts +61 -4
- package/src/input/handler-feed.ts +13 -0
- package/src/input/handler-picker-routes.ts +18 -13
- package/src/input/handler-ui-state.ts +4 -0
- package/src/input/keybindings.ts +5 -0
- package/src/input/model-picker.ts +5 -0
- package/src/input/panel-integration-actions.ts +10 -0
- package/src/main.ts +0 -1
- package/src/panels/agent-inspector-panel.ts +18 -13
- package/src/panels/agent-inspector-shared.ts +3 -3
- package/src/panels/agent-logs-panel.ts +22 -53
- package/src/panels/approval-panel.ts +146 -95
- package/src/panels/automation-control-panel.ts +24 -1
- package/src/panels/base-panel.ts +26 -2
- package/src/panels/builtin/development.ts +1 -1
- package/src/panels/cockpit-panel.ts +24 -5
- package/src/panels/cockpit-read-model.ts +2 -1
- package/src/panels/communication-panel.ts +108 -43
- package/src/panels/context-visualizer-panel.ts +25 -1
- package/src/panels/control-plane-panel.ts +27 -1
- package/src/panels/cost-tracker-panel.ts +82 -54
- package/src/panels/debug-panel.ts +51 -16
- package/src/panels/diff-panel.ts +59 -30
- package/src/panels/docs-panel.ts +19 -3
- package/src/panels/eval-panel.ts +130 -81
- package/src/panels/expandable-list-panel.ts +189 -0
- package/src/panels/file-explorer-panel.ts +40 -41
- package/src/panels/file-preview-panel.ts +9 -2
- package/src/panels/forensics-panel.ts +27 -13
- package/src/panels/git-panel.ts +58 -70
- package/src/panels/hooks-panel.ts +36 -2
- package/src/panels/incident-review-panel.ts +31 -10
- package/src/panels/intelligence-panel.ts +152 -71
- package/src/panels/knowledge-graph-panel.ts +89 -27
- package/src/panels/local-auth-panel.ts +17 -11
- package/src/panels/marketplace-panel.ts +31 -12
- package/src/panels/memory-panel.ts +5 -4
- package/src/panels/ops-control-panel.ts +69 -7
- package/src/panels/ops-strategy-panel.ts +61 -43
- package/src/panels/orchestration-panel.ts +34 -4
- package/src/panels/panel-list-panel.ts +33 -8
- package/src/panels/panel-manager.ts +23 -4
- package/src/panels/plan-dashboard-panel.ts +183 -66
- package/src/panels/plugins-panel.ts +48 -10
- package/src/panels/policy-panel.ts +60 -23
- package/src/panels/polish-core.ts +157 -0
- package/src/panels/polish-tables.ts +258 -0
- package/src/panels/polish.ts +44 -145
- package/src/panels/project-planning-panel.ts +22 -0
- package/src/panels/provider-accounts-panel.ts +55 -18
- package/src/panels/provider-health-panel.ts +65 -23
- package/src/panels/provider-stats-panel.ts +47 -22
- package/src/panels/qr-panel.ts +23 -11
- package/src/panels/remote-panel.ts +12 -5
- package/src/panels/routes-panel.ts +27 -5
- package/src/panels/sandbox-panel.ts +62 -27
- package/src/panels/schedule-panel.ts +40 -17
- package/src/panels/scrollable-list-panel.ts +124 -10
- package/src/panels/security-panel.ts +72 -20
- package/src/panels/services-panel.ts +68 -22
- package/src/panels/session-browser-panel.ts +29 -5
- package/src/panels/session-maintenance.ts +2 -2
- package/src/panels/settings-sync-panel.ts +30 -15
- package/src/panels/subscription-panel.ts +21 -3
- package/src/panels/symbol-outline-panel.ts +40 -47
- package/src/panels/system-messages-panel.ts +60 -27
- package/src/panels/tasks-panel.ts +34 -7
- package/src/panels/thinking-panel.ts +26 -25
- package/src/panels/token-budget-panel.ts +49 -38
- package/src/panels/tool-inspector-panel.ts +27 -21
- package/src/panels/types.ts +25 -0
- package/src/panels/watchers-panel.ts +25 -5
- package/src/panels/work-plan-panel.ts +125 -34
- package/src/panels/worktree-panel.ts +65 -20
- package/src/panels/wrfc-panel-format.ts +133 -0
- package/src/panels/wrfc-panel.ts +31 -127
- package/src/renderer/compaction-preview.ts +2 -1
- package/src/renderer/compositor.ts +46 -43
- package/src/renderer/model-picker-overlay.ts +9 -4
- package/src/renderer/model-workspace.ts +2 -3
- package/src/renderer/panel-composite.ts +7 -20
- package/src/renderer/panel-workspace-bar.ts +14 -8
- package/src/renderer/progress.ts +3 -2
- package/src/renderer/tab-strip.ts +148 -34
- package/src/renderer/ui-factory.ts +4 -6
- package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
- package/src/runtime/ui/model-picker/types.ts +4 -0
- package/src/utils/format-duration.ts +55 -0
- package/src/utils/format-number.ts +71 -0
- package/src/version.ts +1 -1
- package/src/panels/panel-picker.ts +0 -106
- package/src/renderer/panel-picker-overlay.ts +0 -202
- package/src/renderer/panel-tab-bar.ts +0 -69
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* that passes configManager has a coherent call site.
|
|
11
11
|
*/
|
|
12
12
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
13
|
+
import { computeContextUsage } from '../core/context-usage.ts';
|
|
13
14
|
|
|
14
15
|
export type PanelGuidanceMode = 'off' | 'minimal' | 'guided';
|
|
15
16
|
export type PanelSessionMaintenanceLevel = 'stable' | 'watch' | 'suggest-compact' | 'compacting' | 'needs-repair' | 'unknown';
|
|
@@ -76,8 +77,7 @@ export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput):
|
|
|
76
77
|
const thresholdPct = Math.max(0, Number.isFinite(rawThreshold) ? rawThreshold : 0);
|
|
77
78
|
const autoCompactEnabled = thresholdPct > 0;
|
|
78
79
|
|
|
79
|
-
const
|
|
80
|
-
const remainingTokens = Math.max(0, input.contextWindow - input.currentTokens);
|
|
80
|
+
const { pct: usagePct, remaining: remainingTokens } = computeContextUsage(input.currentTokens, input.contextWindow);
|
|
81
81
|
const sessionMemoryCount = Math.max(0, input.sessionMemoryCount ?? 0);
|
|
82
82
|
const compactionCount = Math.max(0, input.session?.lineage?.filter((entry) => entry.branchReason === 'compaction').length ?? 0);
|
|
83
83
|
const lastCompactedAt = input.session?.lastCompactedAt;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
|
+
import { fitDisplay, truncateDisplay } from '../utils/terminal-width.ts';
|
|
2
3
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
3
4
|
import {
|
|
4
5
|
buildDetailBlock,
|
|
5
6
|
buildGuidanceLine,
|
|
7
|
+
buildKeyboardHints,
|
|
6
8
|
buildPanelListRow,
|
|
7
9
|
buildPanelLine,
|
|
8
10
|
buildStatusPill,
|
|
@@ -28,6 +30,14 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
|
|
|
28
30
|
public constructor(private readonly configManager: ConfigManager) {
|
|
29
31
|
super('settings-sync', 'Settings Sync', 'S', 'monitoring');
|
|
30
32
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
33
|
+
this.filterEnabled = true;
|
|
34
|
+
this.filterLabel = 'Filter settings';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
protected override filterMatches(entry: ResolvedEntry, q: string): boolean {
|
|
38
|
+
return entry.key.toLowerCase().includes(q)
|
|
39
|
+
|| String(entry.effectiveSource).toLowerCase().includes(q)
|
|
40
|
+
|| String(entry.effectiveValue).toLowerCase().includes(q);
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
protected override getPalette(): PanelPalette {
|
|
@@ -40,9 +50,9 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
|
|
|
40
50
|
|
|
41
51
|
protected renderItem(entry: ResolvedEntry, _index: number, selected: boolean, width: number): Line {
|
|
42
52
|
return buildPanelListRow(width, [
|
|
43
|
-
{ text: entry.key.padEnd(32), fg: C.value },
|
|
53
|
+
{ text: fitDisplay(entry.key, 32).padEnd(32), fg: C.value },
|
|
44
54
|
{ text: ` ${entry.effectiveSource}`.padEnd(11), fg: entry.effectiveSource === 'managed' ? C.warn : entry.effectiveSource === 'synced' ? C.ok : entry.effectiveSource === 'local' ? C.info : C.dim },
|
|
45
|
-
{ text: `${String(entry.effectiveValue)}
|
|
55
|
+
{ text: truncateDisplay(`${String(entry.effectiveValue)}`, Math.max(0, width - 47)), fg: entry.locked ? C.warn : C.dim },
|
|
46
56
|
], C, { selected });
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -69,28 +79,28 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
|
|
|
69
79
|
...(snapshot.stagedManagedBundle
|
|
70
80
|
? [
|
|
71
81
|
buildPanelLine(width, [[' profile ', C.label], [snapshot.stagedManagedBundle.profileName, C.value], [' risk ', C.label], [snapshot.stagedManagedBundle.risk, snapshot.stagedManagedBundle.risk === 'high' ? C.error : snapshot.stagedManagedBundle.risk === 'medium' ? C.warn : C.ok], [' changes ', C.label], [String(snapshot.stagedManagedBundle.changeCount), C.info]]),
|
|
72
|
-
buildPanelLine(width, [[' path ', C.label], [snapshot.stagedManagedBundle.path
|
|
82
|
+
buildPanelLine(width, [[' path ', C.label], [truncateDisplay(snapshot.stagedManagedBundle.path, Math.max(0, width - 9)), C.dim]]),
|
|
73
83
|
]
|
|
74
84
|
: [buildPanelLine(width, [[' No staged managed settings bundle.', C.dim]])]),
|
|
75
85
|
// Recent Events
|
|
76
86
|
...(snapshot.recentEvents.length > 0
|
|
77
|
-
? snapshot.recentEvents.map((event) => buildPanelLine(width, [[` ${event.surface}/${event.direction}
|
|
87
|
+
? snapshot.recentEvents.map((event) => buildPanelLine(width, [[fitDisplay(` ${event.surface}/${event.direction}`, 18).padEnd(18), C.info], [truncateDisplay(` ${event.detail}`, Math.max(0, width - 20)), C.dim]]))
|
|
78
88
|
: [buildPanelLine(width, [[' No sync or managed-setting events recorded yet.', C.dim]])]),
|
|
79
89
|
// Managed Locks
|
|
80
90
|
...(snapshot.managedLocks.length > 0
|
|
81
|
-
? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[` ${lock.key}
|
|
91
|
+
? snapshot.managedLocks.slice(0, 10).map((lock) => buildPanelLine(width, [[fitDisplay(` ${lock.key}`, 30).padEnd(30), C.value], [fitDisplay(` source=${lock.source}`, 24).padEnd(24), C.info], [truncateDisplay(` ${lock.reason}`, Math.max(0, width - 56)), C.dim]]))
|
|
82
92
|
: [buildPanelLine(width, [[' No managed locks are currently active.', C.dim]])]),
|
|
83
93
|
// Failures
|
|
84
94
|
...(snapshot.recentFailures.length > 0
|
|
85
|
-
? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [` ${failure.message}
|
|
95
|
+
? snapshot.recentFailures.map((failure) => buildPanelLine(width, [[` ${failure.surface}`.padEnd(10), C.error], [truncateDisplay(` ${failure.message}`, Math.max(0, width - 12)), C.dim]]))
|
|
86
96
|
: [buildPanelLine(width, [[' No recent sync or managed-setting failures.', C.dim]])]),
|
|
87
97
|
// Conflicts
|
|
88
98
|
...(snapshot.conflicts.length > 0
|
|
89
|
-
? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[` ${conflict.key}
|
|
99
|
+
? snapshot.conflicts.map((conflict) => buildPanelLine(width, [[fitDisplay(` ${conflict.key}`, 30).padEnd(30), C.value], [fitDisplay(` ${conflict.source}`, 10).padEnd(10), C.warn], [truncateDisplay(` resolve: /settings-sync resolve ${conflict.key} local|synced`, Math.max(0, width - 42)), C.dim]]))
|
|
90
100
|
: [buildPanelLine(width, [[' No settings conflicts detected.', C.dim]])]),
|
|
91
101
|
// Rollback History
|
|
92
102
|
...(snapshot.rollbackHistory.length > 0
|
|
93
|
-
? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[` ${entry.token}
|
|
103
|
+
? snapshot.rollbackHistory.map((entry) => buildPanelLine(width, [[fitDisplay(` ${entry.token}`, 18).padEnd(18), C.info], [fitDisplay(` ${entry.profileName}`, 18).padEnd(18), C.value], [` restored=${String(entry.restoredKeys.length).padEnd(4)}`, C.warn], [truncateDisplay(` ${new Date(entry.appliedAt).toLocaleString()}`, Math.max(0, width - 46)), C.dim]]))
|
|
94
104
|
: [buildPanelLine(width, [[' No managed rollback records yet.', C.dim]])]),
|
|
95
105
|
];
|
|
96
106
|
|
|
@@ -101,14 +111,19 @@ export class SettingsSyncPanel extends ScrollableListPanel<ResolvedEntry> {
|
|
|
101
111
|
? buildDetailBlock(width, 'Selected setting', [
|
|
102
112
|
buildPanelLine(width, [[' key ', C.label], [selectedEntry.key, C.value], [' category ', C.label], [selectedEntry.category, C.info]]),
|
|
103
113
|
buildPanelLine(width, [[' effective ', C.label], [selectedEntry.effectiveSource, selectedEntry.effectiveSource === 'managed' ? C.warn : selectedEntry.effectiveSource === 'synced' ? C.ok : selectedEntry.effectiveSource === 'local' ? C.info : C.dim], [' locked ', C.label], [selectedEntry.locked ? 'yes' : 'no', selectedEntry.locked ? C.warn : C.dim], [' conflict ', C.label], [selectedEntry.conflict ? 'yes' : 'no', selectedEntry.conflict ? C.error : C.good]]),
|
|
104
|
-
buildPanelLine(width, [[' source ', C.label], [(selectedEntry.sourceLabel ?? 'local/default'
|
|
105
|
-
buildPanelLine(width, [[' overrides ', C.label], [(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none'
|
|
106
|
-
buildPanelLine(width, [[' local ', C.label], [String(selectedEntry.localValue)
|
|
107
|
-
buildPanelLine(width, [[' synced ', C.label], [String(selectedEntry.syncedValue ?? '(unset)')
|
|
108
|
-
buildPanelLine(width, [[' managed ', C.label], [String(selectedEntry.managedValue ?? '(unset)')
|
|
114
|
+
buildPanelLine(width, [[' source ', C.label], [truncateDisplay(selectedEntry.sourceLabel ?? 'local/default', Math.max(0, width - 10)), C.dim]]),
|
|
115
|
+
buildPanelLine(width, [[' overrides ', C.label], [truncateDisplay(selectedEntry.overriddenSources.length > 0 ? selectedEntry.overriddenSources.join(', ') : 'none', Math.max(0, width - 13)), C.dim]]),
|
|
116
|
+
buildPanelLine(width, [[' local ', C.label], [truncateDisplay(String(selectedEntry.localValue), Math.max(0, width - 9)), C.dim]]),
|
|
117
|
+
buildPanelLine(width, [[' synced ', C.label], [truncateDisplay(String(selectedEntry.syncedValue ?? '(unset)'), Math.max(0, width - 10)), C.ok]]),
|
|
118
|
+
buildPanelLine(width, [[' managed ', C.label], [truncateDisplay(String(selectedEntry.managedValue ?? '(unset)'), Math.max(0, width - 11)), C.warn]]),
|
|
109
119
|
], C)
|
|
110
|
-
: []),
|
|
111
|
-
|
|
120
|
+
: [buildPanelLine(width, [[' Select a setting above to inspect its effective value, source, and overrides.', C.dim]])]),
|
|
121
|
+
buildKeyboardHints(width, [
|
|
122
|
+
{ keys: '↑/↓', label: 'browse' },
|
|
123
|
+
{ keys: '/', label: 'filter' },
|
|
124
|
+
{ keys: '/settings-sync show <key>', label: 'inspect' },
|
|
125
|
+
{ keys: '/settings-sync resolve <key> <local|synced>', label: 'resolve conflict' },
|
|
126
|
+
], C),
|
|
112
127
|
];
|
|
113
128
|
|
|
114
129
|
return this.renderList(width, height, {
|
|
@@ -1,5 +1,6 @@
|
|
|
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';
|
|
3
4
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
5
|
import type { KeyName } from './types.ts';
|
|
5
6
|
import type { ProviderSubscription, PendingSubscriptionLogin } from '@pellux/goodvibes-sdk/platform/config';
|
|
@@ -9,6 +10,7 @@ import type { ServiceInspectionQuery, SubscriptionAccessQuery } from '../runtime
|
|
|
9
10
|
import {
|
|
10
11
|
buildEmptyState,
|
|
11
12
|
buildGuidanceLine,
|
|
13
|
+
buildKeyboardHints,
|
|
12
14
|
buildKeyValueLine,
|
|
13
15
|
buildPanelListRow,
|
|
14
16
|
buildPanelLine,
|
|
@@ -94,7 +96,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
94
96
|
protected renderItem(row: SubscriptionRow, index: number, selected: boolean, width: number): Line {
|
|
95
97
|
const status = statusOf(row);
|
|
96
98
|
return buildPanelListRow(width, [
|
|
97
|
-
{ text: row.provider
|
|
99
|
+
{ text: fitDisplay(row.provider, 16), fg: C.value },
|
|
98
100
|
{ text: ` ${status.toUpperCase().padEnd(12)}`, fg: statusColor(status) },
|
|
99
101
|
{ text: ` oauth=${row.hasOAuthConfig ? 'yes' : 'no'} `, fg: row.hasOAuthConfig ? C.info : C.dim },
|
|
100
102
|
{ text: ` override=${row.subscription ? 'active' : 'off'}`, fg: row.subscription ? C.good : C.dim },
|
|
@@ -177,6 +179,22 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
177
179
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.rows.length - 1));
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
/** Footer hints that reflect the current view: confirm-pending vs browsing. */
|
|
183
|
+
private buildFooterHint(width: number): Line {
|
|
184
|
+
if (this.confirm) {
|
|
185
|
+
return buildKeyboardHints(width, [
|
|
186
|
+
{ keys: 'y/Enter', label: 'confirm sign out' },
|
|
187
|
+
{ keys: 'n/Esc', label: 'cancel' },
|
|
188
|
+
], C);
|
|
189
|
+
}
|
|
190
|
+
const selected = this.rows[this.selectedIndex];
|
|
191
|
+
const hints: Array<{ keys: string; label: string }> = [{ keys: 'Up/Down', label: 'select' }];
|
|
192
|
+
if (selected?.subscription) hints.push({ keys: 'Enter', label: 'sign out' });
|
|
193
|
+
else if (selected?.hasOAuthConfig) hints.push({ keys: '/subscription login <p> start', label: 'sign in' });
|
|
194
|
+
hints.push({ keys: 'r', label: 'refresh' });
|
|
195
|
+
return buildKeyboardHints(width, hints, C);
|
|
196
|
+
}
|
|
197
|
+
|
|
180
198
|
public render(width: number, height: number): Line[] {
|
|
181
199
|
this.refresh();
|
|
182
200
|
this.clampSelection();
|
|
@@ -214,7 +232,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
214
232
|
sections: [{ lines: [...summaryLines, ...emptyLines] }],
|
|
215
233
|
footerLines: [
|
|
216
234
|
buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
|
|
217
|
-
|
|
235
|
+
this.buildFooterHint(width),
|
|
218
236
|
],
|
|
219
237
|
palette: C,
|
|
220
238
|
});
|
|
@@ -264,7 +282,7 @@ export class SubscriptionPanel extends ScrollableListPanel<SubscriptionRow> {
|
|
|
264
282
|
footer: [
|
|
265
283
|
...detailRows,
|
|
266
284
|
buildGuidanceLine(width, '/subscription login <provider> start', 'start browser-based provider login from the packaged subscription surface', C),
|
|
267
|
-
|
|
285
|
+
this.buildFooterHint(width),
|
|
268
286
|
],
|
|
269
287
|
});
|
|
270
288
|
}
|
|
@@ -3,13 +3,14 @@ import { createEmptyLine } from '../types/grid.ts';
|
|
|
3
3
|
import type { Line } from '../types/grid.ts';
|
|
4
4
|
import {
|
|
5
5
|
buildEmptyState,
|
|
6
|
+
buildKeyboardHints,
|
|
6
7
|
buildPanelLine,
|
|
7
|
-
|
|
8
|
+
buildTreeRow,
|
|
8
9
|
buildPanelWorkspace,
|
|
9
10
|
resolveScrollablePanelSection,
|
|
10
11
|
DEFAULT_PANEL_PALETTE,
|
|
12
|
+
extendPalette,
|
|
11
13
|
} from './polish.ts';
|
|
12
|
-
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
13
14
|
|
|
14
15
|
// ── Symbol types ────────────────────────────────────────────────────────────
|
|
15
16
|
|
|
@@ -25,6 +26,10 @@ export interface SymbolEntry {
|
|
|
25
26
|
|
|
26
27
|
// ── Rendering constants ──────────────────────────────────────────────────────
|
|
27
28
|
|
|
29
|
+
const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
30
|
+
selectedBg: '236',
|
|
31
|
+
});
|
|
32
|
+
|
|
28
33
|
/** ANSI 256-color fg codes per symbol kind. */
|
|
29
34
|
const KIND_COLORS: Record<SymbolKind, string> = {
|
|
30
35
|
function: '87', // cyan
|
|
@@ -36,15 +41,15 @@ const KIND_COLORS: Record<SymbolKind, string> = {
|
|
|
36
41
|
const: '245', // grey
|
|
37
42
|
};
|
|
38
43
|
|
|
39
|
-
/**
|
|
40
|
-
const
|
|
41
|
-
function: '
|
|
42
|
-
method: '
|
|
43
|
-
class: '
|
|
44
|
-
namespace: '
|
|
45
|
-
interface: '
|
|
46
|
-
type: '
|
|
47
|
-
const: '
|
|
44
|
+
/** Single-char type icon for the tree row (kept ASCII for column safety). */
|
|
45
|
+
const KIND_ICONS: Record<SymbolKind, string> = {
|
|
46
|
+
function: 'ƒ',
|
|
47
|
+
method: 'ƒ',
|
|
48
|
+
class: 'C',
|
|
49
|
+
namespace: 'N',
|
|
50
|
+
interface: 'I',
|
|
51
|
+
type: 'T',
|
|
52
|
+
const: 'k',
|
|
48
53
|
};
|
|
49
54
|
|
|
50
55
|
/** Regex patterns to extract symbols. Each produces named groups: kind, name, line. */
|
|
@@ -188,8 +193,10 @@ export class SymbolOutlinePanel extends BasePanel {
|
|
|
188
193
|
this.currentPath
|
|
189
194
|
? 'The current file did not produce outline entries with the lightweight parser heuristics.'
|
|
190
195
|
: 'Load a file in the preview panel to populate its outline here.',
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
this.currentPath
|
|
197
|
+
? []
|
|
198
|
+
: [{ command: '/explorer', summary: 'pick a file in the explorer, then Enter to preview and outline it' }],
|
|
199
|
+
C,
|
|
193
200
|
),
|
|
194
201
|
},
|
|
195
202
|
],
|
|
@@ -201,7 +208,7 @@ export class SymbolOutlinePanel extends BasePanel {
|
|
|
201
208
|
const outlineSection = resolveScrollablePanelSection(width, height, {
|
|
202
209
|
intro: this.currentPath ? this.currentPath : 'Outline the current file into navigable symbols and lightweight parent/child structure.',
|
|
203
210
|
footerLines: [
|
|
204
|
-
|
|
211
|
+
buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Space/←/→', label: 'collapse' }, { keys: 'Enter', label: 'jump to source →' }], DEFAULT_PANEL_PALETTE),
|
|
205
212
|
],
|
|
206
213
|
palette: DEFAULT_PANEL_PALETTE,
|
|
207
214
|
beforeSections: [
|
|
@@ -291,7 +298,7 @@ export class SymbolOutlinePanel extends BasePanel {
|
|
|
291
298
|
},
|
|
292
299
|
],
|
|
293
300
|
footerLines: [
|
|
294
|
-
|
|
301
|
+
buildKeyboardHints(width, [{ keys: '↑/↓', label: 'navigate' }, { keys: 'Space/←/→', label: 'collapse' }, { keys: 'Enter', label: 'jump to source →' }], DEFAULT_PANEL_PALETTE),
|
|
295
302
|
],
|
|
296
303
|
palette: DEFAULT_PANEL_PALETTE,
|
|
297
304
|
});
|
|
@@ -434,10 +441,7 @@ function buildVisibleRows(symbols: SymbolEntry[], collapsed: Set<string>): Visib
|
|
|
434
441
|
return rows;
|
|
435
442
|
}
|
|
436
443
|
|
|
437
|
-
/**
|
|
438
|
-
* Write a string into a Line starting at column x, applying fg/bg/style.
|
|
439
|
-
*/
|
|
440
|
-
/** Render a container header row (class / namespace). */
|
|
444
|
+
/** Render a container header row (class / namespace) via the shared tree row. */
|
|
441
445
|
function _renderHeader(
|
|
442
446
|
width: number,
|
|
443
447
|
row: Extract<VisibleRow, { kind: 'header' }>,
|
|
@@ -445,24 +449,19 @@ function _renderHeader(
|
|
|
445
449
|
bgColor: string,
|
|
446
450
|
collapsed: Set<string>,
|
|
447
451
|
): Line {
|
|
448
|
-
// Collapse indicator
|
|
449
452
|
const isCollapsed = collapsed.has(row.name);
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
{ text:
|
|
458
|
-
|
|
459
|
-
{ text: row.name, fg: isSelected ? '255' : '252', bold: isSelected },
|
|
460
|
-
{ text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
|
|
461
|
-
{ text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
|
|
462
|
-
], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
|
|
453
|
+
return buildTreeRow(width, {
|
|
454
|
+
depth: 0,
|
|
455
|
+
label: row.name,
|
|
456
|
+
icon: KIND_ICONS[row.symbolKind],
|
|
457
|
+
expandable: row.hasChildren,
|
|
458
|
+
expanded: !isCollapsed,
|
|
459
|
+
labelColor: KIND_COLORS[row.symbolKind],
|
|
460
|
+
metadata: [{ text: `:${row.line}`, fg: DEFAULT_PANEL_PALETTE.dim }],
|
|
461
|
+
}, C, { selected: isSelected, selectedBg: bgColor || C.selectedBg });
|
|
463
462
|
}
|
|
464
463
|
|
|
465
|
-
/** Render a regular symbol row. */
|
|
464
|
+
/** Render a regular symbol row via the shared tree row. */
|
|
466
465
|
function _renderSymbol(
|
|
467
466
|
width: number,
|
|
468
467
|
row: Extract<VisibleRow, { kind: 'symbol' }>,
|
|
@@ -470,17 +469,11 @@ function _renderSymbol(
|
|
|
470
469
|
bgColor: string,
|
|
471
470
|
): Line {
|
|
472
471
|
const { symbol, depth } = row;
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
{ text: kindLabel, fg: KIND_COLORS[symbol.kind] },
|
|
481
|
-
{ text: ' ', fg: isSelected ? '255' : '251' },
|
|
482
|
-
{ text: symbol.name, fg: isSelected ? '255' : '251', bold: isSelected },
|
|
483
|
-
{ text: ' '.repeat(gap), fg: DEFAULT_PANEL_PALETTE.dim },
|
|
484
|
-
{ text: lineNumStr, fg: DEFAULT_PANEL_PALETTE.dim },
|
|
485
|
-
], { selected: isSelected, selectedBg: bgColor, fillFg: isSelected ? '255' : '' });
|
|
472
|
+
return buildTreeRow(width, {
|
|
473
|
+
depth,
|
|
474
|
+
label: symbol.name,
|
|
475
|
+
icon: KIND_ICONS[symbol.kind],
|
|
476
|
+
labelColor: KIND_COLORS[symbol.kind],
|
|
477
|
+
metadata: [{ text: `:${symbol.line}`, fg: DEFAULT_PANEL_PALETTE.dim }],
|
|
478
|
+
}, C, { selected: isSelected, selectedBg: bgColor || C.selectedBg });
|
|
486
479
|
}
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
buildEmptyState,
|
|
15
15
|
buildGuidanceLine,
|
|
16
16
|
buildKeyValueLine,
|
|
17
|
+
buildKeyboardHints,
|
|
17
18
|
buildPanelListRow,
|
|
18
19
|
buildPanelLine,
|
|
20
|
+
buildStatusBadge,
|
|
19
21
|
buildSummaryBlock,
|
|
20
22
|
buildPanelWorkspace,
|
|
21
23
|
resolvePrimaryScrollableSection,
|
|
@@ -59,6 +61,12 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
59
61
|
constructor(configManager: ConfigManager, componentHealthMonitor?: ComponentHealthMonitor) {
|
|
60
62
|
super('system-messages', 'System Messages', 'J', 'monitoring', componentHealthMonitor);
|
|
61
63
|
this.configManager = configManager;
|
|
64
|
+
this.filterEnabled = true;
|
|
65
|
+
this.filterLabel = 'Filter messages';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
protected override filterMatches(entry: SystemMessageEntry, q: string): boolean {
|
|
69
|
+
return entry.text.toLowerCase().includes(q) || entry.priority.toLowerCase().includes(q);
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
// ---------------------------------------------------------------------------
|
|
@@ -79,7 +87,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
79
87
|
return buildPanelListRow(width, [
|
|
80
88
|
{ text: `${fmtTime(entry.ts)} `, fg: C.ts },
|
|
81
89
|
{
|
|
82
|
-
text: `${entry.priority === 'high' ? 'HIGH' : 'LOW
|
|
90
|
+
text: `${(entry.priority === 'high' ? 'HIGH' : 'LOW').padEnd(4)} `,
|
|
83
91
|
fg: entry.priority === 'high' ? C.high : C.low,
|
|
84
92
|
bold: entry.priority === 'high',
|
|
85
93
|
},
|
|
@@ -129,6 +137,23 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
129
137
|
// Render — multi-section layout (posture + list + detail)
|
|
130
138
|
// ---------------------------------------------------------------------------
|
|
131
139
|
|
|
140
|
+
// Context-aware footer: navigation keys plus filter keys that reflect the
|
|
141
|
+
// current filter state (active typing / applied query / inactive).
|
|
142
|
+
private footerHints(): Array<{ keys: string; label: string }> {
|
|
143
|
+
const hints: Array<{ keys: string; label: string }> = [
|
|
144
|
+
{ keys: 'j/k', label: 'scroll' },
|
|
145
|
+
{ keys: 'g/G', label: 'jump' },
|
|
146
|
+
];
|
|
147
|
+
if (this.filterActive) {
|
|
148
|
+
hints.push({ keys: 'Esc', label: 'clear filter' });
|
|
149
|
+
} else if (this.filterQuery) {
|
|
150
|
+
hints.push({ keys: '/', label: 'edit filter' }, { keys: 'Esc', label: 'clear filter' });
|
|
151
|
+
} else {
|
|
152
|
+
hints.push({ keys: '/', label: 'filter' });
|
|
153
|
+
}
|
|
154
|
+
return hints;
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
override render(width: number, height: number): Line[] {
|
|
133
158
|
return this.trackedRender(() => {
|
|
134
159
|
const intro = 'Operational system traffic routed out of the main conversation to reduce noise and keep runtime status reviewable.';
|
|
@@ -151,7 +176,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
151
176
|
),
|
|
152
177
|
}],
|
|
153
178
|
footerLines: [
|
|
154
|
-
buildPanelLine(width, [['
|
|
179
|
+
buildPanelLine(width, [[' Low-priority system traffic lands here by default. Routing is configurable via /settings.', C.dim]]),
|
|
155
180
|
],
|
|
156
181
|
palette: C,
|
|
157
182
|
});
|
|
@@ -162,12 +187,16 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
162
187
|
const lowCount = this._messages.length - highCount;
|
|
163
188
|
this.selectedIndex = Math.min(this.selectedIndex, this._messages.length - 1);
|
|
164
189
|
const ui = this.configManager.getRaw().ui;
|
|
190
|
+
const latest = this._messages[this._messages.length - 1];
|
|
165
191
|
const postureLines = [
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
192
|
+
// Severity + recency first: high-priority count leads, newest message age follows.
|
|
193
|
+
buildPanelLine(width, [
|
|
194
|
+
[' ', C.label],
|
|
195
|
+
...buildStatusBadge(highCount > 0 ? 'failed' : 'completed', 'high', { count: highCount }),
|
|
196
|
+
[' ', C.dim],
|
|
197
|
+
...buildStatusBadge('review', 'low', { count: lowCount }),
|
|
198
|
+
...(latest ? ([[' newest ', C.label], [`${fmtTime(latest.ts)}`, C.value]] as Array<[string, string]>) : []),
|
|
199
|
+
]),
|
|
171
200
|
buildKeyValueLine(width, [
|
|
172
201
|
{ label: 'system route', value: ui.systemMessages, valueColor: C.info },
|
|
173
202
|
{ label: 'ops route', value: ui.operationalMessages, valueColor: C.info },
|
|
@@ -176,28 +205,33 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
176
205
|
buildGuidanceLine(width, '/settings', 'adjust where operational and WRFC messages render across panels and conversation', C),
|
|
177
206
|
];
|
|
178
207
|
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
208
|
+
const visible = this.getVisibleItems();
|
|
209
|
+
this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, visible.length - 1));
|
|
210
|
+
const selected = visible[this.selectedIndex];
|
|
211
|
+
const messageRows: Line[] = visible.length > 0
|
|
212
|
+
? visible.map((entry, index) => this.renderItem(entry, index, index === this.selectedIndex, width))
|
|
213
|
+
: [buildPanelLine(width, [[` No messages match "${this.filterQuery.trim()}" (Esc to clear)`, C.dim]])];
|
|
183
214
|
|
|
215
|
+
const filterSection: PanelWorkspaceSection = { lines: [this.buildFilterLine(width)] };
|
|
184
216
|
const postureSection: PanelWorkspaceSection = { lines: buildSummaryBlock(width, 'System posture', postureLines, C) };
|
|
185
|
-
const detailSection: PanelWorkspaceSection =
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
217
|
+
const detailSection: PanelWorkspaceSection = selected
|
|
218
|
+
? {
|
|
219
|
+
title: 'Selected Message',
|
|
220
|
+
lines: [
|
|
221
|
+
buildPanelLine(width, [
|
|
222
|
+
[' Time ', C.label],
|
|
223
|
+
[fmtTime(selected.ts), C.value],
|
|
224
|
+
[' Priority ', C.label],
|
|
225
|
+
[selected.priority, selected.priority === 'high' ? C.high : C.low],
|
|
226
|
+
]),
|
|
227
|
+
...buildBodyText(width, selected.text, C, C.value),
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
: { title: 'Selected Message', lines: [] };
|
|
197
231
|
const messagesSection = resolvePrimaryScrollableSection(width, height, {
|
|
198
232
|
intro,
|
|
199
233
|
palette: C,
|
|
200
|
-
beforeSections: [postureSection],
|
|
234
|
+
beforeSections: [filterSection, postureSection],
|
|
201
235
|
section: {
|
|
202
236
|
title: 'Timeline',
|
|
203
237
|
scrollableLines: messageRows,
|
|
@@ -210,6 +244,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
210
244
|
});
|
|
211
245
|
this.scrollStart = messagesSection.scrollOffset;
|
|
212
246
|
const sections: PanelWorkspaceSection[] = [
|
|
247
|
+
filterSection,
|
|
213
248
|
postureSection,
|
|
214
249
|
messagesSection.section,
|
|
215
250
|
detailSection,
|
|
@@ -219,9 +254,7 @@ export class SystemMessagesPanel extends ScrollableListPanel<SystemMessageEntry>
|
|
|
219
254
|
title: 'System Messages',
|
|
220
255
|
intro,
|
|
221
256
|
sections,
|
|
222
|
-
footerLines: [
|
|
223
|
-
buildPanelLine(width, [[' j/k or Up/Down scroll PgUp/PgDn page g/G jump', C.dim]]),
|
|
224
|
-
],
|
|
257
|
+
footerLines: [buildKeyboardHints(width, this.footerHints(), C)],
|
|
225
258
|
palette: C,
|
|
226
259
|
});
|
|
227
260
|
return lines;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
|
+
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
3
4
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
5
|
import type { RuntimeTask, TaskLifecycleState } from '@/runtime/index.ts';
|
|
5
6
|
import type { ManagedWorktreeMeta } from '@/runtime/index.ts';
|
|
@@ -8,6 +9,7 @@ import {
|
|
|
8
9
|
buildDetailBlock,
|
|
9
10
|
buildEmptyState,
|
|
10
11
|
buildGuidanceLine,
|
|
12
|
+
buildKeyboardHints,
|
|
11
13
|
buildPanelListRow,
|
|
12
14
|
buildPanelLine,
|
|
13
15
|
buildSummaryBlock,
|
|
@@ -152,6 +154,8 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
152
154
|
) {
|
|
153
155
|
super('tasks', 'Tasks', 'J', 'monitoring');
|
|
154
156
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
157
|
+
this.filterEnabled = true;
|
|
158
|
+
this.filterLabel = 'Filter tasks';
|
|
155
159
|
this.readModel = readModel;
|
|
156
160
|
this.worktrees = worktrees;
|
|
157
161
|
this.unsubscribers = [
|
|
@@ -178,6 +182,13 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
178
182
|
];
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
protected override filterMatches(task: RuntimeTask, q: string): boolean {
|
|
186
|
+
return task.title.toLowerCase().includes(q)
|
|
187
|
+
|| task.status.toLowerCase().includes(q)
|
|
188
|
+
|| task.id.toLowerCase().includes(q)
|
|
189
|
+
|| String(task.kind).toLowerCase().includes(q);
|
|
190
|
+
}
|
|
191
|
+
|
|
181
192
|
protected getItems(): readonly RuntimeTask[] {
|
|
182
193
|
if (!this.readModel) return [];
|
|
183
194
|
return sortTasks([...this.readModel.getSnapshot().tasks]);
|
|
@@ -188,7 +199,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
188
199
|
{ text: task.status.padEnd(10), fg: statusColor(task.status) },
|
|
189
200
|
{ text: ` ${kindLabel(task.kind).padEnd(12)}`, fg: C.value },
|
|
190
201
|
{ text: ` ${task.id.slice(0, 8)} `, fg: C.dim },
|
|
191
|
-
{ text: task.title
|
|
202
|
+
{ text: truncateDisplay(task.title, Math.max(0, width - 37)), fg: C.value },
|
|
192
203
|
], C, { selected });
|
|
193
204
|
}
|
|
194
205
|
|
|
@@ -199,7 +210,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
199
210
|
return true;
|
|
200
211
|
}
|
|
201
212
|
if (key === 'end') {
|
|
202
|
-
const tasks = this.
|
|
213
|
+
const tasks = this.getVisibleItems();
|
|
203
214
|
this.selectedIndex = Math.max(0, tasks.length - 1);
|
|
204
215
|
this.markDirty();
|
|
205
216
|
return true;
|
|
@@ -210,7 +221,23 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
210
221
|
public render(width: number, height: number): Line[] {
|
|
211
222
|
this.clampSelection();
|
|
212
223
|
const intro = 'Live task lifecycle, ownership, retries, and result/error details across runtime execution domains.';
|
|
213
|
-
const
|
|
224
|
+
const visibleCount = this.getVisibleItems().length;
|
|
225
|
+
// Context-aware footer: position + only the keys that apply in the current
|
|
226
|
+
// (filtering vs browsing) state.
|
|
227
|
+
const footerLines = [
|
|
228
|
+
this.filterActive
|
|
229
|
+
? buildKeyboardHints(width, [
|
|
230
|
+
{ keys: 'type', label: 'filter tasks' },
|
|
231
|
+
{ keys: 'Enter', label: 'apply' },
|
|
232
|
+
{ keys: 'Esc', label: 'clear' },
|
|
233
|
+
], C)
|
|
234
|
+
: buildKeyboardHints(width, [
|
|
235
|
+
{ keys: visibleCount > 0 ? `${this.selectedIndex + 1}/${visibleCount}` : '0/0', label: 'task' },
|
|
236
|
+
{ keys: '↑/↓', label: 'move' },
|
|
237
|
+
{ keys: 'Home/End', label: 'jump' },
|
|
238
|
+
{ keys: '/', label: 'filter' },
|
|
239
|
+
], C),
|
|
240
|
+
];
|
|
214
241
|
|
|
215
242
|
if (!this.readModel) {
|
|
216
243
|
const workspace = buildPanelWorkspace(width, height, {
|
|
@@ -267,7 +294,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
267
294
|
[' kind ', C.label],
|
|
268
295
|
[selected.kind, C.value],
|
|
269
296
|
[' owner ', C.label],
|
|
270
|
-
[selected.owner
|
|
297
|
+
[truncateDisplay(selected.owner, Math.max(0, width - 46)), C.dim],
|
|
271
298
|
]));
|
|
272
299
|
}
|
|
273
300
|
postureLines.push(
|
|
@@ -354,7 +381,7 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
354
381
|
]]));
|
|
355
382
|
for (const record of attachedWorktrees.records.slice(0, 2)) {
|
|
356
383
|
detailRows.push(buildPanelLine(width, [[
|
|
357
|
-
` ${record.state.padEnd(15)} ${record.path}
|
|
384
|
+
truncateDisplay(` ${record.state.padEnd(15)} ${record.path}`, Math.max(0, width - 2)),
|
|
358
385
|
record.state === 'active' ? C.running : record.state === 'paused' ? C.blocked : C.dim,
|
|
359
386
|
]]));
|
|
360
387
|
}
|
|
@@ -368,14 +395,14 @@ export class TasksPanel extends ScrollableListPanel<RuntimeTask> {
|
|
|
368
395
|
if (selected.error) {
|
|
369
396
|
detailRows.push(buildPanelLine(width, [
|
|
370
397
|
[' Error: ', C.label],
|
|
371
|
-
[selected.error
|
|
398
|
+
[truncateDisplay(selected.error, Math.max(0, width - 10)), C.failed],
|
|
372
399
|
]));
|
|
373
400
|
}
|
|
374
401
|
if (selected.result !== undefined) {
|
|
375
402
|
const resultText = safeJson(selected.result);
|
|
376
403
|
detailRows.push(buildPanelLine(width, [
|
|
377
404
|
[' Result: ', C.label],
|
|
378
|
-
[resultText
|
|
405
|
+
[truncateDisplay(resultText, Math.max(0, width - 11)), C.dim],
|
|
379
406
|
]));
|
|
380
407
|
}
|
|
381
408
|
}
|