@pellux/goodvibes-tui 1.16.1 → 1.18.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 +138 -9
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +5490 -1806
- package/package.json +7 -7
- package/src/cli/completions/generate.ts +5 -4
- package/src/cli/config-overrides.ts +51 -11
- package/src/cli/doctor.ts +4 -5
- package/src/cli/entrypoint.ts +13 -8
- package/src/cli/help.ts +7 -6
- package/src/cli/launch-auto-update.ts +214 -0
- package/src/cli/management-commands.ts +82 -15
- package/src/cli/management.ts +0 -2
- package/src/cli/parser.ts +5 -0
- package/src/cli/status.ts +13 -0
- package/src/cli/surface-command.ts +5 -5
- package/src/cli/tui-startup.ts +60 -97
- package/src/cli/types.ts +2 -0
- package/src/config/index.ts +1 -1
- package/src/config/tui-extension-settings.ts +33 -0
- package/src/core/composer-state.ts +1 -1
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-line-cache.ts +3 -3
- package/src/core/conversation-rendering.ts +18 -9
- package/src/core/conversation.ts +2 -2
- package/src/core/long-task-notifier.ts +7 -7
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +29 -6
- package/src/core/system-message-noise.ts +1 -1
- package/src/core/system-message-router.ts +6 -6
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +40 -1
- package/src/core/turn-event-wiring.ts +14 -8
- package/src/daemon/cli.ts +64 -58
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/service-commands.ts +5 -5
- package/src/export/cost-utils.ts +128 -6
- package/src/input/autocomplete.ts +2 -2
- package/src/input/command-registry.ts +55 -9
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/ci-runtime.ts +5 -1
- package/src/input/commands/codebase-runtime.ts +6 -6
- package/src/input/commands/cost-runtime.ts +1 -1
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/intelligence-runtime.ts +1 -1
- package/src/input/commands/local-auth-runtime.ts +1 -1
- package/src/input/commands/local-provider-runtime.ts +49 -10
- package/src/input/commands/local-runtime.ts +53 -5
- package/src/input/commands/memory.ts +2 -2
- package/src/input/commands/operator-panel-runtime.ts +5 -5
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/permissions-provenance.ts +1 -1
- package/src/input/commands/permissions-runtime.ts +99 -8
- package/src/input/commands/planning-runtime.ts +3 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -1
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/provider-accounts-runtime.ts +71 -6
- package/src/input/commands/provider.ts +7 -5
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/recall-files-git.ts +13 -51
- package/src/input/commands/recall-files.ts +8 -11
- package/src/input/commands/recall-review.ts +2 -2
- package/src/input/commands/relay-runtime.ts +1 -1
- package/src/input/commands/remote-runtime.ts +1 -1
- package/src/input/commands/runtime-services.ts +4 -4
- package/src/input/commands/schedule-runtime.ts +14 -7
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +2 -2
- package/src/input/commands/settings-sync-runtime.ts +2 -2
- package/src/input/commands/share-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +46 -68
- package/src/input/commands/subscription-runtime.ts +1 -1
- package/src/input/commands/update-runtime.ts +153 -96
- package/src/input/commands/work-plan-runtime.ts +1 -1
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +46 -27
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -2
- package/src/input/config-modal-types.ts +14 -3
- package/src/input/config-modal.ts +75 -13
- package/src/input/feature-unit-layout.ts +93 -82
- package/src/input/feed-context-factory.ts +2 -2
- package/src/input/handler-command-route.ts +2 -2
- package/src/input/handler-content-actions.ts +1 -1
- package/src/input/handler-feed-routes.ts +7 -7
- package/src/input/handler-feed.ts +4 -4
- package/src/input/handler-interactions.ts +3 -2
- package/src/input/handler-modal-routes.ts +12 -4
- package/src/input/handler-modal-token-routes.ts +1 -1
- package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
- package/src/input/handler-onboarding.ts +3 -2
- package/src/input/handler-picker-routes.ts +26 -13
- package/src/input/handler-shortcuts.ts +29 -6
- package/src/input/handler.ts +2 -2
- package/src/input/keybindings.ts +21 -3
- package/src/input/onboarding/onboarding-feature-units.ts +34 -27
- package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
- package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
- package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
- package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
- package/src/input/panel-integration-actions.ts +2 -2
- package/src/input/panel-mouse-geometry.ts +1 -1
- package/src/input/panel-paste-flood-guard.ts +1 -1
- package/src/input/provider-key-intake.ts +167 -0
- package/src/input/sandbox-exec-config.ts +2 -2
- package/src/input/selection-modal.ts +1 -1
- package/src/input/settings-modal-activation.ts +5 -0
- package/src/input/settings-modal-adjustment.ts +4 -31
- package/src/input/settings-modal-data.ts +52 -57
- package/src/input/settings-modal-mutations.ts +12 -68
- package/src/input/settings-modal-types.ts +27 -17
- package/src/input/settings-modal.ts +74 -55
- package/src/main.ts +90 -90
- package/src/panels/agent-inspector-shared.ts +4 -4
- package/src/panels/base-panel.ts +2 -2
- package/src/panels/builtin/agent.ts +4 -4
- package/src/panels/builtin/development.ts +4 -4
- package/src/panels/builtin/knowledge.ts +1 -1
- package/src/panels/builtin/operations.ts +83 -11
- package/src/panels/builtin/session.ts +5 -5
- package/src/panels/builtin/shared.ts +16 -4
- package/src/panels/builtin-modals.ts +59 -42
- package/src/panels/builtin-panels.ts +1 -1
- package/src/panels/cost-tracker-panel.ts +124 -7
- package/src/panels/diff-panel.ts +39 -6
- package/src/panels/eval-registry.ts +2 -2
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-deep-link.ts +1 -1
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +163 -11
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +73 -57
- package/src/panels/fleet-read-model.ts +84 -14
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-steer.ts +2 -2
- package/src/panels/fleet-stop.ts +41 -5
- package/src/panels/fleet-tabs.ts +15 -16
- package/src/panels/fleet-transcript.ts +5 -5
- package/src/panels/git-panel.ts +2 -2
- package/src/panels/index.ts +4 -1
- package/src/panels/local-auth-panel.ts +1 -1
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/hooks-modal.ts +1 -1
- package/src/panels/modals/keybindings-modal.ts +2 -2
- package/src/panels/modals/knowledge-modal.ts +1 -1
- package/src/panels/modals/marketplace-modal.ts +4 -4
- package/src/panels/modals/memory-modal.ts +153 -9
- package/src/panels/modals/modal-surface-helpers.ts +1 -1
- package/src/panels/modals/modal-theme.ts +1 -1
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/planning-modal.ts +2 -2
- package/src/panels/modals/plugins-modal.ts +1 -1
- package/src/panels/modals/policy-modal.ts +1 -1
- package/src/panels/modals/security-modal.ts +7 -1
- package/src/panels/modals/skills-modal.ts +1 -1
- package/src/panels/modals/work-plan-modal.ts +3 -3
- package/src/panels/notifications-feed.ts +127 -0
- package/src/panels/notifications-panel.ts +160 -0
- package/src/panels/panel-manager.ts +9 -9
- package/src/panels/plugins-panel.ts +4 -4
- package/src/panels/project-planning-answer-actions.ts +1 -1
- package/src/panels/provider-health-routes.ts +2 -2
- package/src/panels/skills-panel.ts +4 -5
- package/src/panels/token-budget-panel.ts +11 -3
- package/src/panels/types.ts +5 -5
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/permissions/broker-approval-card.ts +201 -0
- package/src/permissions/hunk-selection.ts +28 -4
- package/src/permissions/prompt.ts +244 -29
- package/src/renderer/autocomplete-overlay.ts +107 -54
- package/src/renderer/code-block.ts +2 -2
- package/src/renderer/compaction-history-modal.ts +1 -1
- package/src/renderer/compaction-preview.ts +1 -1
- package/src/renderer/compaction-quality.ts +1 -1
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/compositor.ts +2 -2
- package/src/renderer/config-modal.ts +1 -1
- package/src/renderer/conversation-overlays.ts +1 -1
- package/src/renderer/footer-tips.ts +3 -3
- package/src/renderer/hint-grammar.ts +1 -1
- package/src/renderer/modal-factory.ts +1 -1
- package/src/renderer/model-picker-overlay.ts +1 -1
- package/src/renderer/model-workspace.ts +7 -2
- package/src/renderer/panel-composite.ts +2 -2
- package/src/renderer/panel-workspace-bar.ts +1 -1
- package/src/renderer/process-indicator.ts +1 -1
- package/src/renderer/selection-modal-overlay.ts +1 -1
- package/src/renderer/session-picker-modal.ts +2 -2
- package/src/renderer/settings-modal-helpers.ts +23 -5
- package/src/renderer/settings-modal.ts +129 -39
- package/src/renderer/shell-surface.ts +6 -0
- package/src/renderer/terminal-bg-probe.ts +1 -1
- package/src/renderer/theme.ts +3 -3
- package/src/renderer/tool-call.ts +13 -7
- package/src/renderer/tool-result-summary.ts +1 -1
- package/src/renderer/turn-injection.ts +7 -6
- package/src/renderer/ui-factory.ts +38 -1
- package/src/runtime/bootstrap-command-context.ts +7 -2
- package/src/runtime/bootstrap-command-parts.ts +9 -5
- package/src/runtime/bootstrap-core.ts +41 -41
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap-shell.ts +13 -5
- package/src/runtime/bootstrap.ts +34 -32
- package/src/runtime/channel-composition.ts +46 -0
- package/src/runtime/code-index-services.ts +3 -4
- package/src/runtime/daemon-attach-notices.ts +120 -0
- package/src/runtime/durability-services.ts +90 -0
- package/src/runtime/embedded-service-factories.ts +40 -0
- package/src/runtime/feature-settings.ts +147 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +6 -21
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/legacy-daemon-migration.ts +29 -19
- package/src/runtime/memory-fold.ts +1 -1
- package/src/runtime/notification-dispatch.ts +110 -0
- package/src/runtime/onboarding/apply.ts +3 -29
- package/src/runtime/onboarding/fast-path.ts +39 -6
- package/src/runtime/onboarding/provider-key-capture.ts +126 -0
- package/src/runtime/onboarding/snapshot.ts +0 -1
- package/src/runtime/onboarding/types.ts +9 -3
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/provider-fallback.ts +55 -0
- package/src/runtime/provider-optimizer-wiring.ts +55 -0
- package/src/runtime/release-artifacts.ts +19 -103
- package/src/runtime/resume-notice.ts +1 -1
- package/src/runtime/services.ts +113 -113
- package/src/runtime/session-continuity-hints.ts +38 -0
- package/src/runtime/surface-feature-flags.ts +40 -9
- package/src/runtime/terminal-output-guard.ts +2 -2
- package/src/runtime/trust/workspace-registration.ts +10 -2
- package/src/runtime/trust/workspace-trust.ts +58 -31
- package/src/runtime/ui-services.ts +6 -1
- package/src/runtime/update-check.ts +15 -69
- package/src/runtime/workstream-services.ts +2 -2
- package/src/shell/blocking-input.ts +108 -132
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/shell/ui-openers.ts +38 -6
- package/src/utils/format-duration.ts +1 -1
- package/src/verification/verification-ledger.ts +21 -4
- package/src/version.ts +1 -1
- package/src/input/commands/flags-runtime.ts +0 -328
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NotificationsPanel — the visible home for notifications routed to the
|
|
3
|
+
* `panel_only` target (see notifications-feed.ts for why this exists).
|
|
4
|
+
*
|
|
5
|
+
* Renders every entry the feed has accumulated: standalone panel-routed
|
|
6
|
+
* notifications, and collapsed burst/batch groups shown honestly with their
|
|
7
|
+
* real running count and the plain-language reason they were held back from
|
|
8
|
+
* the conversation. Descriptive text (titles, bodies, reason descriptions)
|
|
9
|
+
* is always wrapped in full — never clipped to a column width.
|
|
10
|
+
*/
|
|
11
|
+
import type { Line } from '../types/grid.ts';
|
|
12
|
+
import type { RoutingDecision } from '@/runtime/index.ts';
|
|
13
|
+
import { BasePanel } from './base-panel.ts';
|
|
14
|
+
import {
|
|
15
|
+
buildEmptyState,
|
|
16
|
+
buildKeyboardHints,
|
|
17
|
+
buildPanelWorkspace,
|
|
18
|
+
buildStyledPanelLine,
|
|
19
|
+
DEFAULT_PANEL_PALETTE,
|
|
20
|
+
resolveScrollablePanelSection,
|
|
21
|
+
} from './polish.ts';
|
|
22
|
+
import { wrapText } from '../utils/terminal-width.ts';
|
|
23
|
+
import { getSharedNotificationFeed, type PanelFeedEntry, type PanelNotificationFeed } from './notifications-feed.ts';
|
|
24
|
+
|
|
25
|
+
// Reuses the shared, mode-aware (dark/light) panel palette's existing
|
|
26
|
+
// bad/warn/info/dim tokens rather than minting new hex literals — this
|
|
27
|
+
// panel has no color needs the shared palette doesn't already name.
|
|
28
|
+
const C = DEFAULT_PANEL_PALETTE;
|
|
29
|
+
|
|
30
|
+
/** Plain-language description of why a notification never reached the conversation or status bar. Every reason code is covered so a new one fails visibly (a TypeScript error on this object) rather than silently. */
|
|
31
|
+
const REASON_DESCRIPTIONS: Record<RoutingDecision['reasonCode'], string> = {
|
|
32
|
+
allowed: 'routed here by domain verbosity',
|
|
33
|
+
quiet_while_typing: 'held while you were typing',
|
|
34
|
+
mode_context_minimal: 'held back by quiet mode',
|
|
35
|
+
mode_context_normal: 'held back by balanced mode',
|
|
36
|
+
burst_collapsed: 'rapid repeats collapsed',
|
|
37
|
+
batch_window_collapsed: 'repeated within a few seconds, collapsed',
|
|
38
|
+
domain_verbosity_low: "below this domain's verbosity setting",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function levelColor(level: PanelFeedEntry['level']): string {
|
|
42
|
+
switch (level) {
|
|
43
|
+
case 'critical': return C.bad;
|
|
44
|
+
case 'warning': return C.warn;
|
|
45
|
+
case 'debug': return C.dim;
|
|
46
|
+
default: return C.info;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function levelTag(level: PanelFeedEntry['level']): string {
|
|
51
|
+
return `[${level}]`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build every Line an entry needs — title/meta row, then the full body and reason text wrapped with no line cap (descriptive text is never clipped). */
|
|
55
|
+
function renderEntry(entry: PanelFeedEntry, width: number): Line[] {
|
|
56
|
+
const lines: Line[] = [];
|
|
57
|
+
const fg = levelColor(entry.level);
|
|
58
|
+
const countSuffix = entry.collapsedCount > 1 ? ` ×${entry.collapsedCount}` : '';
|
|
59
|
+
const headerText = `${levelTag(entry.level)} ${entry.domain} — ${entry.title}${countSuffix}`;
|
|
60
|
+
for (const wrapped of wrapText(headerText, Math.max(1, width - 1))) {
|
|
61
|
+
lines.push(buildStyledPanelLine(width, [{ text: ` ${wrapped}`, fg, bold: true }]));
|
|
62
|
+
}
|
|
63
|
+
const reasonText = entry.collapsedCount > 1
|
|
64
|
+
? ` ${entry.collapsedCount} notifications collapsed — ${REASON_DESCRIPTIONS[entry.reasonCode]}`
|
|
65
|
+
: ` ${REASON_DESCRIPTIONS[entry.reasonCode]}`;
|
|
66
|
+
for (const wrapped of wrapText(reasonText, Math.max(1, width - 1))) {
|
|
67
|
+
lines.push(buildStyledPanelLine(width, [{ text: ` ${wrapped}`, fg: C.dim }]));
|
|
68
|
+
}
|
|
69
|
+
if (entry.body) {
|
|
70
|
+
for (const wrapped of wrapText(` ${entry.body}`, Math.max(1, width - 1))) {
|
|
71
|
+
lines.push(buildStyledPanelLine(width, [{ text: ` ${wrapped}`, fg: C.value }]));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class NotificationsPanel extends BasePanel {
|
|
78
|
+
private readonly unsubscribe: () => void;
|
|
79
|
+
private scrollOffset = 0;
|
|
80
|
+
|
|
81
|
+
constructor(private readonly feed: PanelNotificationFeed = getSharedNotificationFeed()) {
|
|
82
|
+
super('notifications', 'Notifications', 'N', 'runtime-ops');
|
|
83
|
+
this.unsubscribe = this.feed.subscribe(() => this.markDirty());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
override onDestroy(): void {
|
|
87
|
+
this.unsubscribe();
|
|
88
|
+
super.onDestroy();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
handleInput(key: string): boolean {
|
|
92
|
+
switch (key) {
|
|
93
|
+
case 'up': return this.scroll(-1);
|
|
94
|
+
case 'down': return this.scroll(1);
|
|
95
|
+
case 'pageup': return this.scroll(-10);
|
|
96
|
+
case 'pagedown': return this.scroll(10);
|
|
97
|
+
default: return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private scroll(delta: number): boolean {
|
|
102
|
+
const entries = this.feed.list();
|
|
103
|
+
if (entries.length <= 1) return false;
|
|
104
|
+
const maxOffset = Math.max(0, entries.length - 1);
|
|
105
|
+
const prev = this.scrollOffset;
|
|
106
|
+
this.scrollOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset + delta));
|
|
107
|
+
if (this.scrollOffset !== prev) this.markDirty();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(width: number, height: number): Line[] {
|
|
112
|
+
if (width <= 0 || height <= 0) return [];
|
|
113
|
+
const entries = this.feed.list();
|
|
114
|
+
const hintRow = buildKeyboardHints(width, [
|
|
115
|
+
{ keys: 'Up/Down', label: 'scroll' },
|
|
116
|
+
{ keys: 'PgUp/PgDn', label: 'page' },
|
|
117
|
+
], DEFAULT_PANEL_PALETTE);
|
|
118
|
+
|
|
119
|
+
if (entries.length === 0) {
|
|
120
|
+
return buildPanelWorkspace(width, height, {
|
|
121
|
+
title: ' Notifications',
|
|
122
|
+
intro: 'Notifications held back from the conversation or status bar (panel_only) land here, including their real collapsed counts.',
|
|
123
|
+
sections: [{
|
|
124
|
+
lines: buildEmptyState(
|
|
125
|
+
width,
|
|
126
|
+
' No panel-routed notifications yet',
|
|
127
|
+
'Nothing has been routed here this session. Quiet/balanced-mode operational chatter and collapsed bursts will appear here honestly, with their real counts, once something routes to this target.',
|
|
128
|
+
[],
|
|
129
|
+
DEFAULT_PANEL_PALETTE,
|
|
130
|
+
),
|
|
131
|
+
}],
|
|
132
|
+
footerLines: [hintRow],
|
|
133
|
+
palette: DEFAULT_PANEL_PALETTE,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const scrollableLines = entries.flatMap((entry) => renderEntry(entry, width - 2));
|
|
138
|
+
const section = resolveScrollablePanelSection(width, height, {
|
|
139
|
+
intro: 'Notifications held back from the conversation or status bar (panel_only) land here, including their real collapsed counts.',
|
|
140
|
+
footerLines: [hintRow],
|
|
141
|
+
palette: DEFAULT_PANEL_PALETTE,
|
|
142
|
+
section: {
|
|
143
|
+
title: `Notifications (${entries.length})`,
|
|
144
|
+
scrollableLines,
|
|
145
|
+
scrollOffset: this.scrollOffset,
|
|
146
|
+
minRows: 4,
|
|
147
|
+
appendWindowSummary: scrollableLines.length > 0 ? { dimColor: DEFAULT_PANEL_PALETTE.dim } : undefined,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
this.scrollOffset = section.scrollOffset;
|
|
151
|
+
|
|
152
|
+
return buildPanelWorkspace(width, height, {
|
|
153
|
+
title: ' Notifications',
|
|
154
|
+
intro: 'Notifications held back from the conversation or status bar (panel_only) land here, including their real collapsed counts.',
|
|
155
|
+
sections: [section.section],
|
|
156
|
+
footerLines: [hintRow],
|
|
157
|
+
palette: DEFAULT_PANEL_PALETTE,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -34,18 +34,18 @@ export interface WorkspaceTab {
|
|
|
34
34
|
export class PanelManager {
|
|
35
35
|
private registry: PanelRegistration[] = [];
|
|
36
36
|
private retainedPanels = new Map<string, Panel>();
|
|
37
|
-
/** Old/absorbed panel id -> merged target id (
|
|
37
|
+
/** Old/absorbed panel id -> merged target id (from the console panel merges). */
|
|
38
38
|
private aliases = new Map<string, string>();
|
|
39
39
|
/**
|
|
40
|
-
* Retired panel id -> modal name (
|
|
40
|
+
* Retired panel id -> modal name (panels migrated to config-modal surfaces).
|
|
41
41
|
* Unlike `aliases` (panel -> panel), a hit here means no panel is ever
|
|
42
42
|
* constructed for this id — `open()` invokes `openModalCallback` instead
|
|
43
|
-
* and returns a sentinel.
|
|
44
|
-
*
|
|
43
|
+
* and returns a sentinel. This map and the open()-time check are the
|
|
44
|
+
* sole mechanism for that redirect.
|
|
45
45
|
*/
|
|
46
46
|
private modalRedirects = new Map<string, string>();
|
|
47
47
|
/**
|
|
48
|
-
* Modal name -> the surface the config-modal host renders
|
|
48
|
+
* Modal name -> the surface the config-modal host renders. Built once
|
|
49
49
|
* in builtin-modals.ts (closing over read-models) and looked up by
|
|
50
50
|
* ui-openers' openModal callback. Distinct from `modalRedirects` (panel id ->
|
|
51
51
|
* modal name): a redirect resolves a legacy panel id to a name; this map
|
|
@@ -100,7 +100,7 @@ export class PanelManager {
|
|
|
100
100
|
|
|
101
101
|
registerType(registration: PanelRegistration): void {
|
|
102
102
|
const existing = this.registry.findIndex(r => r.id === registration.id);
|
|
103
|
-
//
|
|
103
|
+
// registry-time icon-uniqueness assertion. Tab-bar icons are a
|
|
104
104
|
// single glyph; two panels sharing one silently made the workspace tab
|
|
105
105
|
// strip ambiguous (the historical W/R/U/K/M/Q/Y/J/P collisions). Compare
|
|
106
106
|
// against every OTHER registration (excluding a re-registration of the
|
|
@@ -276,7 +276,7 @@ export class PanelManager {
|
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* Hand an optional deep-link target (
|
|
279
|
+
* Hand an optional deep-link target (item 4) to a panel that just
|
|
280
280
|
* resolved from open(). Panels without a "node" concept simply don't
|
|
281
281
|
* implement `receiveDeepLink` — the call is a no-op via optional chaining.
|
|
282
282
|
*/
|
|
@@ -496,7 +496,7 @@ export class PanelManager {
|
|
|
496
496
|
this.bottomPane.panels.push(panel);
|
|
497
497
|
this.bottomPane.activeIndex = 0;
|
|
498
498
|
} else {
|
|
499
|
-
// Open a predictable default panel in the bottom pane.
|
|
499
|
+
// Open a predictable default panel in the bottom pane. purge:
|
|
500
500
|
// 'panel-list' was deleted (dead weight over a 5-panel registry —
|
|
501
501
|
// see the DELETE disposition), so the default is explicitly
|
|
502
502
|
// 'fleet' rather than falling back to registry[0] (whatever
|
|
@@ -606,7 +606,7 @@ export class PanelManager {
|
|
|
606
606
|
toggle(): void {
|
|
607
607
|
this._visible = !this._visible;
|
|
608
608
|
// Auto-open a default panel if toggling visible with nothing open.
|
|
609
|
-
//
|
|
609
|
+
// purge: explicitly 'fleet' rather than registry[0] — see the
|
|
610
610
|
// matching comment in toggleBottomPane() above.
|
|
611
611
|
if (this._visible && this.topPane.panels.length === 0 && this.bottomPane.panels.length === 0) {
|
|
612
612
|
const defaultPanel = this._getRegistration('fleet') ?? this.registry[0];
|
|
@@ -21,11 +21,11 @@ import {
|
|
|
21
21
|
} from './polish.ts';
|
|
22
22
|
|
|
23
23
|
// Base chrome only — title band, state colors, and text tokens all come
|
|
24
|
-
// straight from DEFAULT_PANEL_PALETTE
|
|
24
|
+
// straight from DEFAULT_PANEL_PALETTE.
|
|
25
25
|
const C = DEFAULT_PANEL_PALETTE;
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* PluginsPanel drives real plugin-manager mutations (enable/disable/
|
|
29
29
|
* verify/lift-quarantine) from the keyboard, not just read-only inspection.
|
|
30
30
|
* `PluginManagerObserver` alone (the read-only surface most panels need) has
|
|
31
31
|
* no mutation methods, so the constructor param is widened to also require
|
|
@@ -87,7 +87,7 @@ export class PluginsPanel extends ScrollableListPanel<PluginStatus> {
|
|
|
87
87
|
|| plugin.version.toLowerCase().includes(q);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
//
|
|
90
|
+
// selection is preserved across onActivate — BasePanel's default
|
|
91
91
|
// (mark dirty, no index reset) is exactly what we want, so no override.
|
|
92
92
|
|
|
93
93
|
public override onDestroy(): void {
|
|
@@ -149,7 +149,7 @@ export class PluginsPanel extends ScrollableListPanel<PluginStatus> {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
152
|
+
* optional one-key capture-to-memory for a quarantined plugin — 'm'
|
|
153
153
|
* dispatches `/recall capture plugin <name>` (recall-capture.ts:146 pattern)
|
|
154
154
|
* instead of requiring the operator to type the command by hand.
|
|
155
155
|
*/
|
|
@@ -37,7 +37,7 @@ function compactAnswerDetail(text: string): string {
|
|
|
37
37
|
* recommendedAnswer echoing one of the scope answers verbatim, must not show
|
|
38
38
|
* the same suggested answer twice), then the fixed ask-narrower/custom actions.
|
|
39
39
|
*
|
|
40
|
-
*
|
|
40
|
+
* dismissing planning is no longer a pseudo answer-row — it is a
|
|
41
41
|
* first-class confirmed modal action (the `d` key), so no dismiss row is
|
|
42
42
|
* produced here. A canned answer records against the current open question via
|
|
43
43
|
* /plan answer; the custom row's free-form text is submitted to chat.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// provider-health-routes.ts
|
|
3
3
|
//
|
|
4
|
-
// Single-source auth-route posture for the provider console (
|
|
4
|
+
// Single-source auth-route posture for the provider console (merge of
|
|
5
5
|
// the retired accounts panel into provider-health). All route data flows from
|
|
6
6
|
// ProviderRuntimeInspectionQuery.inspectAll() snapshots; this module only
|
|
7
7
|
// normalizes descriptors (synthesizing them for providers that do not declare
|
|
@@ -137,7 +137,7 @@ function pickRoute(
|
|
|
137
137
|
/**
|
|
138
138
|
* Derive the console's account/auth posture for one inspectAll() snapshot.
|
|
139
139
|
* This is the only route-posture derivation in the TUI (the retired accounts
|
|
140
|
-
* panel's parallel snapshot model was deleted with
|
|
140
|
+
* panel's parallel snapshot model was deleted with).
|
|
141
141
|
*/
|
|
142
142
|
export function buildAccountPosture(snapshot: ProviderRuntimeSnapshotLike): ProviderAccountPosture {
|
|
143
143
|
const auth = snapshot.runtime.auth;
|
|
@@ -259,7 +259,7 @@ export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
|
|
|
259
259
|
public constructor(options: SkillsPanelOptions) {
|
|
260
260
|
super('skills', 'Skills', '▩', 'automation-control', options.componentHealthMonitor);
|
|
261
261
|
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
262
|
-
this.filterEnabled = true; //
|
|
262
|
+
this.filterEnabled = true; // converged modal '/' filter
|
|
263
263
|
this.filterLabel = 'Filter';
|
|
264
264
|
this.shellPaths = options.shellPaths;
|
|
265
265
|
this.ecosystemPaths = options.ecosystemPaths;
|
|
@@ -367,12 +367,12 @@ export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
|
|
|
367
367
|
|
|
368
368
|
public override onDestroy(): void {}
|
|
369
369
|
|
|
370
|
-
//
|
|
370
|
+
// (the purge): Enter used to open the skill's markdown source in the
|
|
371
371
|
// preview panel via a staged pendingOpenPreview flag + handlePanelIntegrationAction
|
|
372
372
|
// (same pattern diff-panel.ts used). 'preview' is DELETE-disposition with no
|
|
373
373
|
// successor surface (no file-picker-overlay preview to repoint to either —
|
|
374
374
|
// verified), so that cross-panel jump was removed rather than repointed;
|
|
375
|
-
// Enter is currently a no-op on this list (browse-only until
|
|
375
|
+
// Enter is currently a no-op on this list (browse-only until migrates
|
|
376
376
|
// Skills to a modal).
|
|
377
377
|
|
|
378
378
|
public handleInput(key: string): boolean {
|
|
@@ -393,8 +393,7 @@ export class SkillsPanel extends ScrollableListPanel<SkillRecord> {
|
|
|
393
393
|
if (confirmResult === 'absorbed') return true;
|
|
394
394
|
|
|
395
395
|
// I1: 'd' prompts delete confirmation — only outside filter mode, so 'd'
|
|
396
|
-
// remains typeable into the filter query while it is active (
|
|
397
|
-
// converged modal '/' filter coexists with single-letter action keys).
|
|
396
|
+
// remains typeable into the filter query while it is active (// converged modal '/' filter coexists with single-letter action keys).
|
|
398
397
|
if (!this.filterActive && key === 'd') {
|
|
399
398
|
const skill = this.getSelectedItem();
|
|
400
399
|
if (skill) {
|
|
@@ -7,7 +7,7 @@ import type { TurnEvent } from '@/runtime/index.ts';
|
|
|
7
7
|
import type { UiEventFeed } from '../runtime/ui-events.ts';
|
|
8
8
|
import type { UiReadModel, UiSessionSnapshot } from '../runtime/ui-read-models.ts';
|
|
9
9
|
import type { SessionMemoryQuery } from '../runtime/ui-service-queries.ts';
|
|
10
|
-
import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
|
|
10
|
+
import { calcSessionCost, describePricingSource, isModelPriced } from '../export/cost-utils.ts';
|
|
11
11
|
import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
|
|
12
12
|
import type { PanelIntegrationContext } from './types.ts';
|
|
13
13
|
import {
|
|
@@ -460,7 +460,7 @@ export class TokenBudgetPanel extends BasePanel {
|
|
|
460
460
|
|
|
461
461
|
if (status.guidanceMode !== 'off' && status.nextSteps.length > 0) {
|
|
462
462
|
const nextStep = status.nextSteps[0]!;
|
|
463
|
-
//
|
|
463
|
+
// when the recommended next step is /compact and C is armed
|
|
464
464
|
// (see handleInput's elevated-only C branch and the footer hint),
|
|
465
465
|
// advertise the key instead of the command — pressing C already
|
|
466
466
|
// dispatches it for real, so printing '/compact' here was a redundant
|
|
@@ -618,9 +618,17 @@ export class TokenBudgetPanel extends BasePanel {
|
|
|
618
618
|
const modelId = this.getCurrentModelId();
|
|
619
619
|
const priced = isModelPriced(modelId);
|
|
620
620
|
const cost = calcSessionCost(u.input, u.output, u.cacheRead, u.cacheWrite, modelId);
|
|
621
|
+
// Dollars carry their source; an unknown price renders the explicit
|
|
622
|
+
// marker (never $0.00) and names the one-key fix on the cost panel.
|
|
623
|
+
const source = priced ? describePricingSource(modelId) : null;
|
|
621
624
|
lines.push(buildStyledPanelLine(width, [
|
|
622
625
|
{ text: ' Cost: ', fg: C.label },
|
|
623
|
-
{ text: priced ? `$${cost.toFixed(4)}` : '
|
|
626
|
+
{ text: priced ? `$${cost.toFixed(4)}` : 'price unknown', fg: C.value, bold: true },
|
|
627
|
+
...(priced && source
|
|
628
|
+
? [{ text: ` (${source})`, fg: C.dim }]
|
|
629
|
+
: priced
|
|
630
|
+
? []
|
|
631
|
+
: [{ text: ' — set it with p on the cost panel', fg: C.dim }]),
|
|
624
632
|
]));
|
|
625
633
|
}
|
|
626
634
|
|
package/src/panels/types.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface PanelIntegrationContext {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* A cross-panel deep-link target (
|
|
20
|
+
* A cross-panel deep-link target (item 4): a specific node a panel
|
|
21
21
|
* should select + reveal when opened via `PanelManager.open(id, pane, target)`,
|
|
22
22
|
* e.g. a Fleet tree node keyed by its process id and (optionally) its
|
|
23
23
|
* ProcessKind for disambiguation. Panel-agnostic on purpose — only panels that
|
|
@@ -56,7 +56,7 @@ export type NamedKey =
|
|
|
56
56
|
export type KeyName = NamedKey | (string & {});
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
*
|
|
59
|
+
* the former single 'monitoring' bucket held 33 panels pre-merge —
|
|
60
60
|
* too coarse for the picker to be useful and too large for any one operator
|
|
61
61
|
* mental model. Split into named operator domains so each category holds a
|
|
62
62
|
* bounded, coherent set (no category may exceed 10 registrations):
|
|
@@ -97,7 +97,7 @@ export interface Panel {
|
|
|
97
97
|
isPinned: boolean;
|
|
98
98
|
needsRender: boolean;
|
|
99
99
|
|
|
100
|
-
// Dirty-flag contract (
|
|
100
|
+
// Dirty-flag contract (activated panel render skipping)
|
|
101
101
|
/** Mark this panel as needing a re-render on the next frame. */
|
|
102
102
|
invalidate(): void;
|
|
103
103
|
/** Called by the compositor after a successful render to clear the dirty flag. */
|
|
@@ -154,7 +154,7 @@ export interface Panel {
|
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
156
|
* Optional: consume a deep-link target handed in by `PanelManager.open(id,
|
|
157
|
-
* pane, target)` (
|
|
157
|
+
* pane, target)` (item 4) — select + reveal the matching node on the
|
|
158
158
|
* panel's next snapshot. A panel without a "node" concept simply doesn't
|
|
159
159
|
* implement this (the call is a no-op via optional chaining in
|
|
160
160
|
* PanelManager). A panel that DOES implement it owns its own honest
|
|
@@ -169,7 +169,7 @@ export interface PanelRegistration extends Pick<Panel, 'id' | 'name' | 'icon' |
|
|
|
169
169
|
factory: () => Panel;
|
|
170
170
|
description: string;
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
172
|
+
* lifecycle flags. `preload` and `retainOnClose` are independent —
|
|
173
173
|
* a panel can eagerly instantiate without being kept alive on close, or be
|
|
174
174
|
* kept alive on close without eager bootstrap instantiation. They happened
|
|
175
175
|
* to be identical for all builtin panels before this split (both driven by
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// workstream-graph-render.ts — render a workstream's task graph (fleet.graph.get)
|
|
3
|
+
// legibly, so the observability layer shows the dependency graph's shape,
|
|
4
|
+
// states, and pool posture WITHOUT opening any transcript.
|
|
5
|
+
//
|
|
6
|
+
// The SDK serves a WorkstreamGraphSnapshot: nodes (id/title/state, plus
|
|
7
|
+
// blockedReason, orphaned, stalled, agentId), edges (from → to dependency
|
|
8
|
+
// links), and the elastic-pool state (ready/running/atCap/maxSize). This module
|
|
9
|
+
// is a pure string renderer; every line is fit to the given width (no clipping,
|
|
10
|
+
// no horizontal overflow), so the same output drives a panel or a command.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
import type { OperatorMethodOutput } from '@pellux/goodvibes-sdk';
|
|
14
|
+
import { truncateDisplay, getDisplayWidth } from '../utils/terminal-width.ts';
|
|
15
|
+
|
|
16
|
+
export type WorkstreamGraphSnapshot = OperatorMethodOutput<'fleet.graph.get'>;
|
|
17
|
+
export type WorkstreamGraphNode = WorkstreamGraphSnapshot['nodes'][number];
|
|
18
|
+
|
|
19
|
+
/** The pool posture line: "N ready, M running[, at cap (fleet.maxSize=N)]". */
|
|
20
|
+
export function renderPoolSummary(pool: WorkstreamGraphSnapshot['pool']): string {
|
|
21
|
+
if (!pool) return 'no pool state';
|
|
22
|
+
const base = `${pool.ready} ready, ${pool.running} running`;
|
|
23
|
+
return pool.atCap ? `${base}, at cap (fleet.maxSize=${pool.maxSize})` : base;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The leading glyph for a node — orphaned/blocked/stalled tells win over the base state. */
|
|
27
|
+
function nodeGlyph(node: WorkstreamGraphNode): string {
|
|
28
|
+
if (node.orphaned) return '⊘';
|
|
29
|
+
if (node.blockedReason) return '⊘';
|
|
30
|
+
if (node.stalled) return '◒';
|
|
31
|
+
switch (node.state) {
|
|
32
|
+
case 'running': return '●';
|
|
33
|
+
case 'ready': return '○';
|
|
34
|
+
case 'done': return '✓';
|
|
35
|
+
case 'failed': return '✕';
|
|
36
|
+
default: return '·';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The state tell for a node: the "waiting on: X" for blocked, running/ready, plus orphaned/stalled markers. */
|
|
41
|
+
function nodeTell(node: WorkstreamGraphNode, needs: readonly string[]): string {
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
if (node.blockedReason) {
|
|
44
|
+
parts.push(`blocked — waiting on: ${node.blockedReason}`);
|
|
45
|
+
} else if (node.state === 'running') {
|
|
46
|
+
parts.push(node.agentId ? `running (${node.agentId})` : 'running');
|
|
47
|
+
} else if (node.state === 'ready') {
|
|
48
|
+
parts.push('ready');
|
|
49
|
+
} else {
|
|
50
|
+
parts.push(node.state);
|
|
51
|
+
}
|
|
52
|
+
if (needs.length > 0 && !node.blockedReason) parts.push(`needs: ${needs.join(', ')}`);
|
|
53
|
+
if (node.orphaned) parts.push('orphaned');
|
|
54
|
+
if (node.stalled) parts.push('stalled');
|
|
55
|
+
return parts.join(' · ');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fit a line to the width without overflowing (truncates with an ellipsis). */
|
|
59
|
+
function fit(line: string, width: number): string {
|
|
60
|
+
return getDisplayWidth(line) > width ? truncateDisplay(line, width) : line;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wrap content to the width on word boundaries — NO clipping: the whole tell
|
|
65
|
+
* (including a long "waiting on: …" reason) survives onto indented continuation
|
|
66
|
+
* lines rather than being cut off. `firstIndent` leads the first line;
|
|
67
|
+
* `contIndent` leads each continuation. A single word longer than the width is
|
|
68
|
+
* hard-fit defensively so a line never overflows.
|
|
69
|
+
*/
|
|
70
|
+
function wrapContent(content: string, width: number, firstIndent: string, contIndent: string): string[] {
|
|
71
|
+
const words = content.split(/\s+/).filter(Boolean);
|
|
72
|
+
const out: string[] = [];
|
|
73
|
+
let cur = firstIndent;
|
|
74
|
+
let hasWord = false;
|
|
75
|
+
for (const word of words) {
|
|
76
|
+
const candidate = hasWord ? `${cur} ${word}` : `${cur}${word}`;
|
|
77
|
+
if (getDisplayWidth(candidate) > width && hasWord) {
|
|
78
|
+
out.push(cur);
|
|
79
|
+
cur = `${contIndent}${word}`;
|
|
80
|
+
} else {
|
|
81
|
+
cur = candidate;
|
|
82
|
+
}
|
|
83
|
+
hasWord = true;
|
|
84
|
+
}
|
|
85
|
+
if (hasWord) out.push(cur);
|
|
86
|
+
return out.map((l) => (getDisplayWidth(l) > width ? fit(l, width) : l));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render the whole snapshot as legible lines, each fit to `width`. The header
|
|
91
|
+
* carries the pool posture; every node is one line with its glyph, title, and
|
|
92
|
+
* state tell (ready / running / blocked-with-waiting-on / stalled / orphaned),
|
|
93
|
+
* with its dependency edges named inline ("needs: …"). An empty graph states so
|
|
94
|
+
* honestly rather than rendering a blank surface.
|
|
95
|
+
*/
|
|
96
|
+
export function renderWorkstreamGraphLines(snapshot: WorkstreamGraphSnapshot, width: number): string[] {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
// Title and pool posture on their own lines so neither clips the other at a
|
|
99
|
+
// narrow width — the pool summary ("… at cap (fleet.maxSize=N)") is load-bearing.
|
|
100
|
+
lines.push(fit(snapshot.title, width));
|
|
101
|
+
lines.push(fit(` ${renderPoolSummary(snapshot.pool)}`, width));
|
|
102
|
+
|
|
103
|
+
if (snapshot.nodes.length === 0) {
|
|
104
|
+
lines.push(fit(' (no work items in this workstream)', width));
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const titleById = new Map<string, string>();
|
|
109
|
+
for (const node of snapshot.nodes) titleById.set(node.id, node.title);
|
|
110
|
+
const needsByNode = new Map<string, string[]>();
|
|
111
|
+
for (const edge of snapshot.edges) {
|
|
112
|
+
const list = needsByNode.get(edge.to) ?? [];
|
|
113
|
+
list.push(titleById.get(edge.from) ?? edge.from);
|
|
114
|
+
needsByNode.set(edge.to, list);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const node of snapshot.nodes) {
|
|
118
|
+
const glyph = nodeGlyph(node);
|
|
119
|
+
const tell = nodeTell(node, needsByNode.get(node.id) ?? []);
|
|
120
|
+
lines.push(...wrapContent(`${glyph} ${node.title} ${tell}`, width, ' ', ' '));
|
|
121
|
+
}
|
|
122
|
+
return lines;
|
|
123
|
+
}
|