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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-maestro-settings-core",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Versioned Settings and i18n contracts shared by Pi Maestro plugins.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -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.11.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.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.9.0",
75
+ "pi-maestro-teammate": "1.10.0",
76
76
  "typebox": "1.3.7",
77
77
  "typescript": "^5.5.0"
78
78
  },
@@ -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
+ }
@@ -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(`steer aborted: @${this.agentName(targetId)} is gone`, true);
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
- return [fit(selected ? `Agents ${entries.length} · ${agentLabel(selected)} · Esc` : "Agents · none · Esc", safeWidth)];
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("No agents", width))];
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("No agent selected", width))];
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
- "PgUp/PgDn scroll output",
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("Agents"),
249
- `${rows.length} total`,
250
- active ? `${active} active` : "",
251
- failed ? `${failed} 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("Enter send · Esc cancel", width));
267
+ return this.params.theme.fg("dim", fit(tuiT("overlay.agents.composeHelp"), width));
261
268
  }
262
- return this.params.theme.fg("dim", fit("Esc close · ↑↓ agent · Home/End jump · PgUp/PgDn output · i interrupt · s steer", width));
269
+ return this.params.theme.fg("dim", fit(tuiT("overlay.agents.help"), width));
263
270
  }
264
271
 
265
272
  private rule(width: number): string {
@@ -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 = 30_000;
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 now - row.lastActivityAt >= AGENT_STALL_TIMEOUT_MS ? "stalled" : "running";
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} failed`;
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 ? `${prefix}${base}${sep}${live} agents` : `${prefix}${base}${sep}working`;
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}`;