pi-cockpit 0.11.0 → 0.12.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/node_modules/pi-maestro-settings-core/package.json +1 -1
- package/node_modules/pi-maestro-settings-core/src/public/v1/i18n.ts +49 -0
- package/package.json +4 -4
- package/src/agent-bar.ts +330 -0
- package/src/agent-overlay.ts +18 -11
- package/src/agents-store.ts +22 -3
- package/src/ambient.ts +6 -3
- package/src/bash-bg-overlay.ts +38 -28
- package/src/bash-bg-widget.ts +8 -3
- package/src/edit-guard.ts +306 -18
- package/src/endpoint-store.ts +459 -0
- package/src/index.ts +535 -175
- package/src/input-routing.ts +41 -8
- package/src/model-picker.ts +7 -6
- package/src/public/v1/events.ts +11 -0
- package/src/render.ts +43 -23
- package/src/session-bar.ts +7 -170
- package/src/session-detail.ts +22 -9
- package/src/session-tabs.ts +93 -0
- package/src/session-ui-state.ts +185 -0
- package/src/settings/locale-state.ts +182 -138
- package/src/settings-view.ts +29 -1
- package/src/sidebar-controller.ts +6 -5
- package/src/sidebar-render.ts +61 -38
- package/src/split-pane.ts +15 -14
- package/src/stack-widget.ts +12 -8
- package/src/theme-picker.ts +13 -12
- package/src/thinking-timer.ts +5 -4
- package/src/transcript-selection.ts +4 -3
- package/src/tui-i18n.ts +609 -0
- package/src/window-bar.ts +94 -0
- package/src/window-thread-view.ts +111 -0
|
@@ -21,6 +21,11 @@ export interface SettingsTranslatorOptions {
|
|
|
21
21
|
onMissing?: MissingTranslationCallback;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface SystemSettingsLocaleOptions {
|
|
25
|
+
environment?: Readonly<Record<string, string | undefined>>;
|
|
26
|
+
resolvedLocale?: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
export interface SettingsTranslator {
|
|
25
30
|
readonly locale: SupportedSettingsLocale;
|
|
26
31
|
setLocale(locale: string | null | undefined): SupportedSettingsLocale;
|
|
@@ -50,6 +55,50 @@ export function normalizeSettingsLocale(locale: string | null | undefined): Supp
|
|
|
50
55
|
return "en";
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
export function detectSystemSettingsLocale(
|
|
59
|
+
options: SystemSettingsLocaleOptions = {},
|
|
60
|
+
): SupportedSettingsLocale {
|
|
61
|
+
const environment = options.environment ?? systemEnvironment();
|
|
62
|
+
const environmentLocale = [
|
|
63
|
+
environment.LC_ALL,
|
|
64
|
+
environment.LC_MESSAGES,
|
|
65
|
+
environment.LANGUAGE?.split(":", 1)[0],
|
|
66
|
+
environment.LANG,
|
|
67
|
+
].find((value) => value?.trim());
|
|
68
|
+
if (environmentLocale) return normalizeSettingsLocale(environmentLocale);
|
|
69
|
+
|
|
70
|
+
const resolvedLocale = options.resolvedLocale === undefined
|
|
71
|
+
? systemResolvedLocale()
|
|
72
|
+
: options.resolvedLocale;
|
|
73
|
+
return normalizeSettingsLocale(resolvedLocale);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveSettingsLocale(
|
|
77
|
+
locale: string | null | undefined,
|
|
78
|
+
options: SystemSettingsLocaleOptions = {},
|
|
79
|
+
): SupportedSettingsLocale {
|
|
80
|
+
const requested = locale?.trim();
|
|
81
|
+
if (requested && requested.toLowerCase() !== "auto") {
|
|
82
|
+
return normalizeSettingsLocale(requested);
|
|
83
|
+
}
|
|
84
|
+
return detectSystemSettingsLocale(options);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function systemEnvironment(): Readonly<Record<string, string | undefined>> {
|
|
88
|
+
const processLike = (globalThis as {
|
|
89
|
+
process?: { env?: Record<string, string | undefined> };
|
|
90
|
+
}).process;
|
|
91
|
+
return processLike?.env ?? {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function systemResolvedLocale(): string | undefined {
|
|
95
|
+
try {
|
|
96
|
+
return Intl.DateTimeFormat().resolvedOptions().locale;
|
|
97
|
+
} catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
53
102
|
export function interpolateTranslation(template: string, params: TranslationParams = {}): string {
|
|
54
103
|
return template.replace(/\{([A-Za-z0-9_.-]+)\}/g, (placeholder, name: string) => {
|
|
55
104
|
if (!Object.hasOwn(params, name)) return placeholder;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cockpit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"scripts": {
|
|
21
|
-
"test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/input-routing.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts",
|
|
21
|
+
"test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/editor-bottom.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/edit-guard.test.ts tests/agents-store.test.ts tests/endpoint-store.test.ts tests/session-ui-state.test.ts tests/session-tabs.test.ts tests/agent-bar.test.ts tests/supervision-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/agent-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/input-routing.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts tests/model-picker.test.ts tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/tui-i18n.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.test.ts tests/capturing-overlay.test.ts tests/claude-editor.test.ts tests/fullscreen-controller.test.ts tests/transcript-selection.test.ts tests/terminal-cleanup.test.ts tests/terminal-capability.test.ts",
|
|
22
22
|
"typecheck": "tsc --noEmit",
|
|
23
23
|
"prepack": "node ../../scripts/bundle-settings-core.mjs",
|
|
24
24
|
"postpack": "node ../../scripts/bundle-settings-core.mjs --clean"
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
]
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"pi-maestro-settings-core": "0.1.
|
|
35
|
+
"pi-maestro-settings-core": "0.1.2",
|
|
36
36
|
"proper-lockfile": "4.1.2"
|
|
37
37
|
},
|
|
38
38
|
"bundledDependencies": [
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
73
73
|
"@earendil-works/pi-tui": "0.83.0",
|
|
74
74
|
"@types/node": "^22.0.0",
|
|
75
|
-
"pi-maestro-teammate": "1.
|
|
75
|
+
"pi-maestro-teammate": "1.10.0",
|
|
76
76
|
"typebox": "1.3.7",
|
|
77
77
|
"typescript": "^5.5.0"
|
|
78
78
|
},
|
package/src/agent-bar.ts
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Bar and the legacy Session Bar facade implementation.
|
|
3
|
+
*
|
|
4
|
+
* Agent Bar consumes canonical local session endpoints and SessionUiState. The
|
|
5
|
+
* compatibility exports at the bottom preserve the former AgentRow-based API.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { CockpitEndpoint } from "./endpoint-store.ts";
|
|
11
|
+
import type { SessionUiState } from "./session-ui-state.ts";
|
|
12
|
+
import { formatUnreadCount, type SessionTab } from "./session-tabs.ts";
|
|
13
|
+
import type { AgentRow } from "./types.ts";
|
|
14
|
+
import { effectiveAgentStatus, type AgentDisplayStatus } from "./agents-store.ts";
|
|
15
|
+
import { visibleAgentRows } from "./stack-widget.ts";
|
|
16
|
+
|
|
17
|
+
export const SESSION_BAR_WIDGET_KEY = "cockpit-session-bar";
|
|
18
|
+
export const AGENT_BAR_WIDGET_KEY = SESSION_BAR_WIDGET_KEY;
|
|
19
|
+
|
|
20
|
+
/** Chip label for the main (root) session. */
|
|
21
|
+
export const MAIN_SESSION_LABEL = "main";
|
|
22
|
+
|
|
23
|
+
/** Stable compatibility order for legacy AgentRow callers. */
|
|
24
|
+
export function byStartOrder(a: AgentRow, b: AgentRow): number {
|
|
25
|
+
return a.startedAt - b.startedAt || a.correlationId.localeCompare(b.correlationId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SessionBarDeps {
|
|
29
|
+
getAgents: () => AgentRow[];
|
|
30
|
+
/** Stable owner clock snapshot; render never reads the wall clock itself. */
|
|
31
|
+
getNow: () => number;
|
|
32
|
+
/** Whether the main (root) agent is currently working. */
|
|
33
|
+
isMainRunning?: () => boolean;
|
|
34
|
+
/** Current session shown at the right edge (@label + status color). */
|
|
35
|
+
getCurrentSession?: () => { label: string; color: ThemeColor } | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AgentBarDeps {
|
|
39
|
+
getEndpoints: () => readonly CockpitEndpoint[];
|
|
40
|
+
getState: () => SessionUiState;
|
|
41
|
+
getNow: () => number;
|
|
42
|
+
isMainRunning?: () => boolean;
|
|
43
|
+
getShortcutHint?: () => string | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const AGENT_SESSION_COLORS = [
|
|
47
|
+
"accent",
|
|
48
|
+
"warning",
|
|
49
|
+
"success",
|
|
50
|
+
"mdLink",
|
|
51
|
+
"thinkingLow",
|
|
52
|
+
"thinkingMedium",
|
|
53
|
+
"thinkingHigh",
|
|
54
|
+
] as const satisfies readonly ThemeColor[];
|
|
55
|
+
|
|
56
|
+
/** Stable pseudo-random color per agent lifecycle identity; never flickers. */
|
|
57
|
+
export function assignedAgentColor(correlationId: string): ThemeColor {
|
|
58
|
+
let hash = 2166136261;
|
|
59
|
+
for (const char of correlationId) {
|
|
60
|
+
hash ^= char.codePointAt(0) ?? 0;
|
|
61
|
+
hash = Math.imul(hash, 16777619);
|
|
62
|
+
}
|
|
63
|
+
return AGENT_SESSION_COLORS[(hash >>> 0) % AGENT_SESSION_COLORS.length];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function agentSessionColor(row: AgentRow, now: number, stableStatus?: AgentDisplayStatus): ThemeColor {
|
|
67
|
+
const status = stableStatus ?? effectiveAgentStatus(row, now);
|
|
68
|
+
if (status === "done" || status === "sleeping" || status === "terminated") return "muted";
|
|
69
|
+
if (status === "failed" || status === "stalled") return "error";
|
|
70
|
+
return assignedAgentColor(row.correlationId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Map an agent display status to a theme color slot. */
|
|
74
|
+
export function statusColor(status: AgentDisplayStatus): ThemeColor {
|
|
75
|
+
switch (status) {
|
|
76
|
+
case "running":
|
|
77
|
+
case "retrying":
|
|
78
|
+
return "warning";
|
|
79
|
+
case "stalled":
|
|
80
|
+
case "failed":
|
|
81
|
+
case "terminated":
|
|
82
|
+
return "error";
|
|
83
|
+
case "done":
|
|
84
|
+
case "result-ready":
|
|
85
|
+
return "success";
|
|
86
|
+
default:
|
|
87
|
+
return "muted";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function chip(label: string, active: boolean, color: ThemeColor, theme: Theme, unread = 0): string {
|
|
92
|
+
const text = `@${label}`;
|
|
93
|
+
const badge = unread > 0 ? theme.fg("warning", ` •${formatUnreadCount(unread)}`) : "";
|
|
94
|
+
return active
|
|
95
|
+
? `${theme.fg(color, "▸")} ${theme.fg(color, theme.bold(text))}${badge}`
|
|
96
|
+
: `${theme.fg(color, text)}${badge}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type AgentBarStatus = AgentDisplayStatus | "idle";
|
|
100
|
+
|
|
101
|
+
interface AgentBarTab extends SessionTab {
|
|
102
|
+
endpoint: CockpitEndpoint;
|
|
103
|
+
status: AgentBarStatus;
|
|
104
|
+
color: ThemeColor;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function endpointStatus(endpoint: CockpitEndpoint, now: number, mainRunning: boolean): AgentBarStatus {
|
|
108
|
+
if (endpoint.kind === "root") return mainRunning ? "running" : "idle";
|
|
109
|
+
if (endpoint.agentRow) return effectiveAgentStatus(endpoint.agentRow, now);
|
|
110
|
+
if (endpoint.status === "sleeping") return "sleeping";
|
|
111
|
+
if (endpoint.status === "settled") return "done";
|
|
112
|
+
return "running";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function endpointColor(endpoint: CockpitEndpoint, status: AgentBarStatus, now: number, selected: boolean): ThemeColor {
|
|
116
|
+
if (selected) return "accent";
|
|
117
|
+
if (endpoint.kind === "root") return status === "running" ? "warning" : "muted";
|
|
118
|
+
if (endpoint.agentRow) return agentSessionColor(endpoint.agentRow, now, status === "idle" ? undefined : status);
|
|
119
|
+
if (status === "done" || status === "sleeping" || status === "terminated") return "muted";
|
|
120
|
+
if (status === "failed" || status === "stalled") return "error";
|
|
121
|
+
return assignedAgentColor(endpoint.correlationId ?? endpoint.id);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface AgentBarRenderOptions {
|
|
125
|
+
mainRunning?: boolean;
|
|
126
|
+
now?: number;
|
|
127
|
+
shortcutHint?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Chip-to-chip separator in the agent bar. */
|
|
131
|
+
const CHIP_SEPARATOR = " ";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One-line horizontal panning for the agent chip list. When the chips overflow
|
|
135
|
+
* the width, the window is anchored so the selected chip sits at the right edge
|
|
136
|
+
* with as many preceding chips as fit, and dim ◀N / N▶ markers count what stays
|
|
137
|
+
* hidden on each side — arrow-cycling always keeps the highlighted selection
|
|
138
|
+
* and its context visible no matter how many agents exist.
|
|
139
|
+
*/
|
|
140
|
+
function renderAgentChipLine(tabs: AgentBarTab[], selectedId: string, width: number, theme: Theme): string {
|
|
141
|
+
const w = Math.max(1, width);
|
|
142
|
+
const selectedIndex = Math.max(0, tabs.findIndex((tab) => tab.id === selectedId));
|
|
143
|
+
const chips = tabs.map((tab) => chip(tab.label, tab.id === selectedId, tab.color, theme, tab.unread));
|
|
144
|
+
const chipWidths = chips.map((text) => visibleWidth(text));
|
|
145
|
+
const sepWidth = visibleWidth(CHIP_SEPARATOR);
|
|
146
|
+
const totalWidth = chipWidths.reduce((sum, chipWidth) => sum + chipWidth + sepWidth, 0) - sepWidth;
|
|
147
|
+
if (totalWidth <= w) return chips.join(CHIP_SEPARATOR);
|
|
148
|
+
|
|
149
|
+
const selectedWidth = chipWidths[selectedIndex] ?? 0;
|
|
150
|
+
// Farthest-left start whose window [start..selectedIndex] still fits budget.
|
|
151
|
+
const fitStart = (budget: number): number => {
|
|
152
|
+
let start = selectedIndex;
|
|
153
|
+
let acc = selectedWidth;
|
|
154
|
+
while (start > 0) {
|
|
155
|
+
const next = chipWidths[start - 1] ?? 0;
|
|
156
|
+
if (acc + sepWidth + next > budget) break;
|
|
157
|
+
acc += sepWidth + next;
|
|
158
|
+
start -= 1;
|
|
159
|
+
}
|
|
160
|
+
return start;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
let start = fitStart(w);
|
|
164
|
+
// Markers are sized from the window itself; a wider marker shrinks the
|
|
165
|
+
// budget, which moves the start right, which can widen the left marker.
|
|
166
|
+
// Start only ever moves right, so this converges and terminates.
|
|
167
|
+
for (;;) {
|
|
168
|
+
const leftHidden = start;
|
|
169
|
+
const rightHidden = tabs.length - selectedIndex - 1;
|
|
170
|
+
const leftMarker = leftHidden > 0 ? theme.fg("dim", `◀${leftHidden}`) : "";
|
|
171
|
+
const rightMarker = rightHidden > 0 ? theme.fg("dim", `${rightHidden}▶`) : "";
|
|
172
|
+
const markerCount = (leftMarker ? 1 : 0) + (rightMarker ? 1 : 0);
|
|
173
|
+
const budget = w - visibleWidth(leftMarker) - visibleWidth(rightMarker) - markerCount * sepWidth;
|
|
174
|
+
if (budget < selectedWidth) {
|
|
175
|
+
// Too narrow for the selected chip beside the markers: show it alone.
|
|
176
|
+
return truncateToWidth(chips[selectedIndex] ?? "", w, "…");
|
|
177
|
+
}
|
|
178
|
+
const next = fitStart(budget);
|
|
179
|
+
if (next === start) {
|
|
180
|
+
const window = chips.slice(start, selectedIndex + 1);
|
|
181
|
+
return [leftMarker, ...window, rightMarker].filter(Boolean).join(CHIP_SEPARATOR);
|
|
182
|
+
}
|
|
183
|
+
start = next;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Keep the mode-switch hint at the right edge without hiding the selected tab. */
|
|
188
|
+
export function renderSessionBarLine(
|
|
189
|
+
renderContent: (width: number) => string,
|
|
190
|
+
width: number,
|
|
191
|
+
theme: Theme,
|
|
192
|
+
shortcutHint?: string,
|
|
193
|
+
): string {
|
|
194
|
+
const w = Math.max(1, width);
|
|
195
|
+
if (!shortcutHint) return renderContent(w);
|
|
196
|
+
const hint = theme.fg("dim", shortcutHint);
|
|
197
|
+
const hintWidth = visibleWidth(hint);
|
|
198
|
+
const gap = 2;
|
|
199
|
+
if (w < hintWidth + gap + 8) return renderContent(w);
|
|
200
|
+
const contentWidth = w - hintWidth - gap;
|
|
201
|
+
const content = renderContent(contentWidth);
|
|
202
|
+
const padding = Math.max(gap, w - visibleWidth(content) - hintWidth);
|
|
203
|
+
return `${content}${" ".repeat(padding)}${hint}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Render canonical main + local-agent chips with selection highlight and horizontal panning. */
|
|
207
|
+
export function renderAgentBar(
|
|
208
|
+
endpoints: readonly CockpitEndpoint[],
|
|
209
|
+
state: SessionUiState,
|
|
210
|
+
width: number,
|
|
211
|
+
theme: Theme,
|
|
212
|
+
options: AgentBarRenderOptions = {},
|
|
213
|
+
): string[] {
|
|
214
|
+
if (endpoints.length === 0) return [truncateToWidth(chip(MAIN_SESSION_LABEL, true, "accent", theme), Math.max(1, width), "…")];
|
|
215
|
+
const now = options.now ?? 0;
|
|
216
|
+
// A stale selection (id no longer in the endpoint list) must not leave the
|
|
217
|
+
// bar without a highlighted chip: fall back to root / first endpoint.
|
|
218
|
+
const fallbackId = endpoints.find((endpoint) => endpoint.kind === "root")?.id ?? endpoints[0]?.id;
|
|
219
|
+
const requestedId = state.selectedId("agent");
|
|
220
|
+
const selectedId = requestedId && endpoints.some((endpoint) => endpoint.id === requestedId)
|
|
221
|
+
? requestedId
|
|
222
|
+
: fallbackId;
|
|
223
|
+
const tabs: AgentBarTab[] = endpoints.map((endpoint) => {
|
|
224
|
+
const selected = endpoint.id === selectedId;
|
|
225
|
+
const status = endpointStatus(endpoint, now, options.mainRunning === true);
|
|
226
|
+
return {
|
|
227
|
+
id: endpoint.id,
|
|
228
|
+
label: endpoint.label,
|
|
229
|
+
ordinal: endpoint.ordinal,
|
|
230
|
+
unread: state.endpoint(endpoint.id).unread,
|
|
231
|
+
endpoint,
|
|
232
|
+
status,
|
|
233
|
+
color: endpointColor(endpoint, status, now, selected),
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
return [renderSessionBarLine(
|
|
237
|
+
(availableWidth) => renderAgentChipLine(tabs, selectedId, availableWidth, theme),
|
|
238
|
+
width,
|
|
239
|
+
theme,
|
|
240
|
+
options.shortcutHint,
|
|
241
|
+
)];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function makeAgentBarWidget(deps: AgentBarDeps) {
|
|
245
|
+
return (_tui: TUI, theme: Theme) => ({
|
|
246
|
+
render(width: number): string[] {
|
|
247
|
+
return renderAgentBar(deps.getEndpoints(), deps.getState(), width, theme, {
|
|
248
|
+
mainRunning: deps.isMainRunning?.(),
|
|
249
|
+
now: deps.getNow(),
|
|
250
|
+
shortcutHint: deps.getShortcutHint?.(),
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
invalidate(): void {},
|
|
254
|
+
dispose(): void {},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface SessionBarRenderOptions {
|
|
259
|
+
mainRunning?: boolean;
|
|
260
|
+
current?: { label: string; color: ThemeColor } | undefined;
|
|
261
|
+
now?: number;
|
|
262
|
+
stableStatuses?: Map<string, AgentDisplayStatus>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Legacy AgentRow-based pure renderer retained for downstream compatibility. */
|
|
266
|
+
export function renderSessionBar(
|
|
267
|
+
rows: AgentRow[],
|
|
268
|
+
width: number,
|
|
269
|
+
theme: Theme,
|
|
270
|
+
opts: SessionBarRenderOptions = {},
|
|
271
|
+
): string[] {
|
|
272
|
+
const agents = visibleAgentRows(rows).sort(byStartOrder);
|
|
273
|
+
const viewingId = agents.find((row) => row.viewing)?.correlationId;
|
|
274
|
+
const now = opts.now ?? agents.reduce((latest, row) => Math.max(latest, row.lastActivityAt), 0);
|
|
275
|
+
const liveIds = new Set(agents.map((row) => row.correlationId));
|
|
276
|
+
if (opts.stableStatuses) {
|
|
277
|
+
for (const id of opts.stableStatuses.keys()) {
|
|
278
|
+
if (!liveIds.has(id)) opts.stableStatuses.delete(id);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const chips = [
|
|
282
|
+
chip(
|
|
283
|
+
MAIN_SESSION_LABEL,
|
|
284
|
+
viewingId === undefined,
|
|
285
|
+
viewingId === undefined ? "accent" : opts.mainRunning ? "warning" : "muted",
|
|
286
|
+
theme,
|
|
287
|
+
),
|
|
288
|
+
...agents.map((row) => {
|
|
289
|
+
const label = row.name || row.role || row.agent || "agent";
|
|
290
|
+
const nextStatus = effectiveAgentStatus(row, now);
|
|
291
|
+
const previousStatus = opts.stableStatuses?.get(row.correlationId);
|
|
292
|
+
const stableStatus = previousStatus === nextStatus ? previousStatus : nextStatus;
|
|
293
|
+
opts.stableStatuses?.set(row.correlationId, stableStatus);
|
|
294
|
+
const color = agentSessionColor(row, now, stableStatus);
|
|
295
|
+
return chip(label, row.correlationId === viewingId, color, theme);
|
|
296
|
+
}),
|
|
297
|
+
];
|
|
298
|
+
const w = Math.max(1, width);
|
|
299
|
+
const chipsLine = chips.join(" ");
|
|
300
|
+
const rawStatus = opts.current
|
|
301
|
+
? theme.fg(opts.current.color, `● @${opts.current.label}`)
|
|
302
|
+
: "";
|
|
303
|
+
if (!rawStatus) {
|
|
304
|
+
return [truncateToWidth(chipsLine, w, "…")];
|
|
305
|
+
}
|
|
306
|
+
const status = truncateToWidth(rawStatus, w, "…");
|
|
307
|
+
const measuredStatus = visibleWidth(status);
|
|
308
|
+
if (measuredStatus >= w) return [status];
|
|
309
|
+
const leftBudget = Math.max(0, w - measuredStatus - 2);
|
|
310
|
+
if (leftBudget === 0) return [" ".repeat(w - measuredStatus) + status];
|
|
311
|
+
const left = truncateToWidth(chipsLine, leftBudget, "…");
|
|
312
|
+
const pad = Math.max(0, w - visibleWidth(left) - measuredStatus);
|
|
313
|
+
return [left + " ".repeat(pad) + status];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function makeSessionBarWidget(deps: SessionBarDeps) {
|
|
317
|
+
const stableStatuses = new Map<string, AgentDisplayStatus>();
|
|
318
|
+
return (_tui: TUI, theme: Theme) => ({
|
|
319
|
+
render(width: number): string[] {
|
|
320
|
+
return renderSessionBar(deps.getAgents(), width, theme, {
|
|
321
|
+
mainRunning: deps.isMainRunning?.(),
|
|
322
|
+
current: deps.getCurrentSession?.(),
|
|
323
|
+
now: deps.getNow(),
|
|
324
|
+
stableStatuses,
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
invalidate(): void {},
|
|
328
|
+
dispose(): void {},
|
|
329
|
+
});
|
|
330
|
+
}
|
package/src/agent-overlay.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "./session-detail.ts";
|
|
20
20
|
import { visibleAgentRows } from "./stack-widget.ts";
|
|
21
21
|
import type { AgentRow } from "./types.ts";
|
|
22
|
+
import { tuiT } from "./tui-i18n.ts";
|
|
22
23
|
import { overlayListRows } from "./viewport.ts";
|
|
23
24
|
|
|
24
25
|
export interface AgentOverlayParams {
|
|
@@ -73,7 +74,7 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
73
74
|
// re-target another agent because the roster selection moved.
|
|
74
75
|
const target = this.entries().find((entry) => entry.row.correlationId === targetId)?.row;
|
|
75
76
|
if (!target) {
|
|
76
|
-
this.ack(
|
|
77
|
+
this.ack(tuiT("overlay.agents.aborted", { agent: this.agentName(targetId) }), true);
|
|
77
78
|
} else if (draft) {
|
|
78
79
|
this.params.onCommand(targetId, "steer", draft);
|
|
79
80
|
this.ack(`steer @${target.name || target.role || target.agent || targetId}: ${draft}`);
|
|
@@ -158,7 +159,13 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
158
159
|
return [fit(`steer @${target}: ${this.compose.draft}_ · Enter · Esc`, safeWidth)];
|
|
159
160
|
}
|
|
160
161
|
const selected = entries.find((entry) => entry.row.correlationId === this.selectedId)?.row;
|
|
161
|
-
|
|
162
|
+
const title = tuiT("overlay.agents.title");
|
|
163
|
+
return [fit(
|
|
164
|
+
selected
|
|
165
|
+
? `${title} ${entries.length} · ${agentLabel(selected)} · Esc`
|
|
166
|
+
: `${title} · ${tuiT("overlay.agents.none")} · Esc`,
|
|
167
|
+
safeWidth,
|
|
168
|
+
)];
|
|
162
169
|
}
|
|
163
170
|
return safeWidth < 88
|
|
164
171
|
? this.renderNarrow(safeWidth, entries.map((entry) => entry.row))
|
|
@@ -204,7 +211,7 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
204
211
|
|
|
205
212
|
private agentLines(width: number, maxRows: number): string[] {
|
|
206
213
|
const entries = this.entries();
|
|
207
|
-
if (entries.length === 0) return [this.params.theme.fg("dim", fit("
|
|
214
|
+
if (entries.length === 0) return [this.params.theme.fg("dim", fit(tuiT("overlay.agents.noAgents"), width))];
|
|
208
215
|
const selected = Math.max(0, entries.findIndex((entry) => entry.row.correlationId === this.selectedId));
|
|
209
216
|
const start = visibleStart(selected, entries.length, maxRows);
|
|
210
217
|
return entries.slice(start, start + maxRows).map(({ row, prefix }) => {
|
|
@@ -222,7 +229,7 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
222
229
|
private detailLines(rows: AgentRow[], width: number, maxRows: number): string[] {
|
|
223
230
|
this.detailWidth = width;
|
|
224
231
|
this.detailRows = maxRows;
|
|
225
|
-
if (!this.selectedId) return [this.params.theme.fg("dim", fit("
|
|
232
|
+
if (!this.selectedId) return [this.params.theme.fg("dim", fit(tuiT("overlay.agents.noSelection"), width))];
|
|
226
233
|
return renderSessionDetail(
|
|
227
234
|
rows,
|
|
228
235
|
this.selectedId,
|
|
@@ -230,7 +237,7 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
230
237
|
this.params.theme,
|
|
231
238
|
maxRows,
|
|
232
239
|
this.outputScroll,
|
|
233
|
-
"
|
|
240
|
+
tuiT("session.previewHint"),
|
|
234
241
|
);
|
|
235
242
|
}
|
|
236
243
|
|
|
@@ -245,10 +252,10 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
245
252
|
const failed = statuses.filter((status) => status === "failed" || status === "stalled").length;
|
|
246
253
|
const ack = this.lastAck && Date.now() - this.lastAck.at < COMMAND_ACK_MS ? this.lastAck : null;
|
|
247
254
|
return fit([
|
|
248
|
-
this.params.theme.bold("
|
|
249
|
-
|
|
250
|
-
active ?
|
|
251
|
-
failed ?
|
|
255
|
+
this.params.theme.bold(tuiT("overlay.agents.title")),
|
|
256
|
+
tuiT("common.total", { count: rows.length }),
|
|
257
|
+
active ? tuiT("common.active", { count: active }) : "",
|
|
258
|
+
failed ? tuiT("common.failed", { count: failed }) : "",
|
|
252
259
|
ack
|
|
253
260
|
? this.params.theme.fg(ack.error ? "error" : "success", ack.error ? `✗ ${ack.text}` : `${this.params.glyphs.check} ${ack.text}`)
|
|
254
261
|
: "",
|
|
@@ -257,9 +264,9 @@ export class AgentOverlay implements Component, Focusable {
|
|
|
257
264
|
|
|
258
265
|
private help(width: number): string {
|
|
259
266
|
if (this.compose) {
|
|
260
|
-
return this.params.theme.fg("dim", fit("
|
|
267
|
+
return this.params.theme.fg("dim", fit(tuiT("overlay.agents.composeHelp"), width));
|
|
261
268
|
}
|
|
262
|
-
return this.params.theme.fg("dim", fit("
|
|
269
|
+
return this.params.theme.fg("dim", fit(tuiT("overlay.agents.help"), width));
|
|
263
270
|
}
|
|
264
271
|
|
|
265
272
|
private rule(width: number): string {
|
package/src/agents-store.ts
CHANGED
|
@@ -8,6 +8,21 @@ import { sanitizeExtensionStatusText } from "./extension-status.ts";
|
|
|
8
8
|
import type { AgentRow, AgentStatus } from "./types.ts";
|
|
9
9
|
|
|
10
10
|
const STATUS_TEXT_MAX = 48;
|
|
11
|
+
const TEAMMATE_STALL_TIMEOUT_MS = 30_000;
|
|
12
|
+
const TEAMMATE_EXPECTED_SILENCE_TIMEOUT_MS = 5 * 60_000;
|
|
13
|
+
const EXPECTED_SILENCE_PHASES = new Set(["starting", "restoring", "prompting", "compacting", "continuing", "settling"]);
|
|
14
|
+
|
|
15
|
+
function isAgentStalled(
|
|
16
|
+
projection: { status: string; phase?: string; resultReadyAt?: number; lastActivityAt?: number; pendingInteractions?: number },
|
|
17
|
+
now: number,
|
|
18
|
+
): boolean {
|
|
19
|
+
if ((projection.status !== "running" && projection.status !== "pending") || projection.resultReadyAt !== undefined) return false;
|
|
20
|
+
if ((projection.pendingInteractions ?? 0) > 0 || projection.lastActivityAt === undefined) return false;
|
|
21
|
+
const idleCeiling = projection.status === "pending" || projection.phase === "retrying" || (projection.phase !== undefined && EXPECTED_SILENCE_PHASES.has(projection.phase))
|
|
22
|
+
? TEAMMATE_EXPECTED_SILENCE_TIMEOUT_MS
|
|
23
|
+
: TEAMMATE_STALL_TIMEOUT_MS;
|
|
24
|
+
return now - projection.lastActivityAt >= idleCeiling;
|
|
25
|
+
}
|
|
11
26
|
/** Selected-session output kept for the expandable fixed detail region. */
|
|
12
27
|
export const SESSION_CONTENT_MAX = 2_048;
|
|
13
28
|
|
|
@@ -113,13 +128,17 @@ function activityFromProgressStatus(status: unknown): "running" | "sleeping" | u
|
|
|
113
128
|
: "running";
|
|
114
129
|
}
|
|
115
130
|
|
|
116
|
-
export const AGENT_STALL_TIMEOUT_MS =
|
|
131
|
+
export const AGENT_STALL_TIMEOUT_MS = TEAMMATE_STALL_TIMEOUT_MS;
|
|
117
132
|
export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
|
|
118
133
|
|
|
119
134
|
export function effectiveAgentStatus(row: AgentRow, now: number = Date.now()): AgentDisplayStatus {
|
|
120
|
-
if (row.status !== "running") return row.status;
|
|
135
|
+
if (row.status !== "running" && row.status !== "pending") return row.status;
|
|
121
136
|
if (row.resultReadyAt !== undefined) return "result-ready";
|
|
122
|
-
return
|
|
137
|
+
return isAgentStalled({
|
|
138
|
+
status: row.status,
|
|
139
|
+
phase: row.phase,
|
|
140
|
+
lastActivityAt: row.lastActivityAt,
|
|
141
|
+
}, now) ? "stalled" : row.status;
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
function normalizeStartedAt(value: number | string | undefined, fallback: number): number {
|
package/src/ambient.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { AgentRow, BashBgJob, TodoItem } from "./types.ts";
|
|
11
11
|
import { formatThinkingDuration } from "./thinking-timer.ts";
|
|
12
|
+
import { tuiT } from "./tui-i18n.ts";
|
|
12
13
|
|
|
13
14
|
export interface AmbientState {
|
|
14
15
|
todos: readonly TodoItem[];
|
|
@@ -60,7 +61,7 @@ function failedJobs(jobs: readonly BashBgJob[]): BashBgJob[] {
|
|
|
60
61
|
* elapsed value, matching the compact folded-thinking label.
|
|
61
62
|
*/
|
|
62
63
|
export function workingMessage(state: AmbientState, now = Date.now()): string | undefined {
|
|
63
|
-
const label = state.activeTool ?? (state.running ? "working" : undefined);
|
|
64
|
+
const label = state.activeTool ?? (state.running ? tuiT("ambient.working") : undefined);
|
|
64
65
|
if (!label) return undefined;
|
|
65
66
|
const sep = state.separator ?? " ";
|
|
66
67
|
const text = (state.hideLiveDuration || state.workingStartedAt === undefined)
|
|
@@ -98,10 +99,12 @@ export function titleFor(
|
|
|
98
99
|
const prefix = state.frame ? `${state.frame} ` : "";
|
|
99
100
|
let title: string;
|
|
100
101
|
if (broken > 0) {
|
|
101
|
-
title = `${marks.fail} ${base}${sep}${broken}
|
|
102
|
+
title = `${marks.fail} ${base}${sep}${tuiT("ambient.failed", { count: broken })}`;
|
|
102
103
|
} else if (state.running) {
|
|
103
104
|
const live = liveAgents(state.agents).length;
|
|
104
|
-
title = live > 0
|
|
105
|
+
title = live > 0
|
|
106
|
+
? `${prefix}${base}${sep}${tuiT("ambient.agents", { count: live })}`
|
|
107
|
+
: `${prefix}${base}${sep}${tuiT("ambient.working")}`;
|
|
105
108
|
} else {
|
|
106
109
|
const jobs = state.jobs.filter((job) => job.status === "running").length;
|
|
107
110
|
title = jobs > 0 ? `${prefix}${base}${sep}${jobs} bg` : `${prefix}${base}`;
|