@north-light/crouter 0.3.278 → 0.3.280
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/dist/api/dto/model-config.d.ts +25 -0
- package/dist/api/dto/model-config.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +11 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +11 -3
- package/dist/clients/attach/chrome/status-line.js +1 -22
- package/dist/clients/attach/render/chat-view.d.ts +11 -0
- package/dist/clients/attach/render/chat-view.js +23 -4
- package/dist/clients/attach/render/tool-calls.d.ts +6 -1
- package/dist/clients/attach/render/tool-calls.js +47 -3
- package/dist/clients/attach/session/bindings.d.ts +1 -1
- package/dist/clients/attach/session/bindings.js +1 -1
- package/dist/clients/attach/session/chrome-refresh.d.ts +3 -0
- package/dist/clients/attach/session/chrome-refresh.js +3 -3
- package/dist/clients/attach/viewer.js +557 -557
- package/dist/commands/node/bash.js +5 -8
- package/dist/core/__tests__/helpers/fleet.d.ts +1 -1
- package/dist/core/__tests__/helpers/fleet.js +5 -0
- package/dist/core/__tests__/helpers/harness.js +5 -1
- package/dist/core/model-config.d.ts +20 -0
- package/dist/core/model-config.js +107 -0
- package/dist/core/model-routes.d.ts +5 -0
- package/dist/core/model-routes.js +5 -5
- package/dist/core/runtime/busy.d.ts +13 -0
- package/dist/core/runtime/busy.js +43 -1
- package/dist/core/runtime/revive.js +2 -0
- package/dist/daemon/api/__tests__/model-config.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/model-config.test.js +73 -0
- package/dist/daemon/api/handlers/model-config.d.ts +2 -0
- package/dist/daemon/api/handlers/model-config.js +44 -0
- package/dist/daemon/api/server.js +2 -0
- package/dist/daemon/fleet.d.ts +3 -0
- package/dist/daemon/fleet.js +35 -5
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +14 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +18 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type ModelStrengthDTO = 'ultra' | 'strong' | 'medium' | 'light';
|
|
2
|
+
export interface ModelRouteConfigDTO {
|
|
3
|
+
family: string;
|
|
4
|
+
credentialSource: string;
|
|
5
|
+
providerId: string;
|
|
6
|
+
models: Record<ModelStrengthDTO, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface ModelRoutingDTO {
|
|
9
|
+
credentialSourceOrder?: string[];
|
|
10
|
+
familyOrder?: string[];
|
|
11
|
+
strengthFallback?: ModelStrengthDTO[];
|
|
12
|
+
}
|
|
13
|
+
export interface PutModelConfigRequest {
|
|
14
|
+
config_version: string;
|
|
15
|
+
modelRoutes: Record<string, ModelRouteConfigDTO>;
|
|
16
|
+
modelRouting: ModelRoutingDTO;
|
|
17
|
+
kinds: Record<string, {
|
|
18
|
+
model: string;
|
|
19
|
+
orchestratorModel?: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export interface PutModelConfigResponse {
|
|
23
|
+
config_version: string;
|
|
24
|
+
delivered: true;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -4,8 +4,9 @@ import { anthropicMessagesApi, openAICodexResponsesApi } from '@earendil-works/p
|
|
|
4
4
|
import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, INVALID_REFRESH_TOKEN_BACKOFF_MS, formatStatusLine, getProviderLabel, isManagedProvider, isInvalidRefreshTokenError, addSubscription, enrichManagedLoginIdentity, markSubscriptionAuthenticationFailed, markSubscriptionRateLimited, markSubscriptionSuccess, parseRetryAfterHeader, promoteSubscription, readRotationConfig, readSubscriptionPool, refreshSubscriptionCredential, removeManagedAccount, resolveProviderCandidates, upsertSubscriptionWithUniqueAccount, } from '../../../core/subscription-state.js';
|
|
5
5
|
import { readMergedLaunchConfig } from '../../../core/config.js';
|
|
6
6
|
import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
|
|
7
|
-
import { envModelExact } from '../../../shared/env.js';
|
|
7
|
+
import { envModelExact, envNodeId } from '../../../shared/env.js';
|
|
8
8
|
import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
|
|
9
|
+
import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
|
|
9
10
|
import { recognizeProviderError } from '../../../core/provider-error-recognition.js';
|
|
10
11
|
// A response without Retry-After proves only that THIS attempt was throttled; it
|
|
11
12
|
// does not authorize crouter to invent a five-minute provider-wide outage. Keep
|
|
@@ -102,8 +103,10 @@ export function __setSleepForTest(fn) {
|
|
|
102
103
|
// heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
|
|
103
104
|
// fresh process: self-terminate via SIGTERM and let the daemon's exit policy
|
|
104
105
|
// respawn this broker against the same session. A genuine full network outage
|
|
105
|
-
//
|
|
106
|
-
//
|
|
106
|
+
// trips this too, and a fresh process cannot heal that one — so the daemon's
|
|
107
|
+
// respawn path probes network reachability before it counts a relaunch, and an
|
|
108
|
+
// offline box parks the node until the network returns instead of spending its
|
|
109
|
+
// respawn budget (daemon/fleet.ts, `deferRespawnWhileOffline`).
|
|
107
110
|
export const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
108
111
|
function defaultTerminatePoisonedProcess(tried) {
|
|
109
112
|
// Only the broker process itself may self-terminate: runBroker stamps
|
|
@@ -111,6 +114,11 @@ function defaultTerminatePoisonedProcess(tried) {
|
|
|
111
114
|
// keeps the ordinary terminal-error path.
|
|
112
115
|
if (process.env["CRTR_BROKER_PID"] !== String(process.pid))
|
|
113
116
|
return false;
|
|
117
|
+
const routes = tried.split(', ').filter((route) => route !== '');
|
|
118
|
+
const nodeId = envNodeId();
|
|
119
|
+
if (nodeId !== undefined && routes.length > 0) {
|
|
120
|
+
recordTransportPoisonVerdict(nodeId, { consecutivePasses: POISONED_TRANSPORT_PASS_LIMIT, routes });
|
|
121
|
+
}
|
|
114
122
|
logRotation(`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`);
|
|
115
123
|
process.kill(process.pid, "SIGTERM");
|
|
116
124
|
return true;
|
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
} from '../../../core/subscription-state.js';
|
|
40
40
|
import { readMergedLaunchConfig } from '../../../core/config.js';
|
|
41
41
|
import { expandModelCandidates, modelRequestFromConfig, probeRouteAvailability } from '../../../core/model-routes.js';
|
|
42
|
-
import { envModelExact } from '../../../shared/env.js';
|
|
42
|
+
import { envModelExact, envNodeId } from '../../../shared/env.js';
|
|
43
43
|
import { ManagedProviderCoolingError } from '../../../core/runtime/managed-provider-cooling.js';
|
|
44
|
+
import { recordTransportPoisonVerdict } from '../../../core/runtime/busy.js';
|
|
44
45
|
import { recognizeProviderError, type ProviderErrorSignal } from '../../../core/provider-error-recognition.js';
|
|
45
46
|
|
|
46
47
|
// A response without Retry-After proves only that THIS attempt was throttled; it
|
|
@@ -197,8 +198,10 @@ export function __setSleepForTest(fn: typeof defaultSleep | undefined): void {
|
|
|
197
198
|
// heal (the dispatcher cannot be safely re-instantiated), so the recovery is a
|
|
198
199
|
// fresh process: self-terminate via SIGTERM and let the daemon's exit policy
|
|
199
200
|
// respawn this broker against the same session. A genuine full network outage
|
|
200
|
-
//
|
|
201
|
-
//
|
|
201
|
+
// trips this too, and a fresh process cannot heal that one — so the daemon's
|
|
202
|
+
// respawn path probes network reachability before it counts a relaunch, and an
|
|
203
|
+
// offline box parks the node until the network returns instead of spending its
|
|
204
|
+
// respawn budget (daemon/fleet.ts, `deferRespawnWhileOffline`).
|
|
202
205
|
export const POISONED_TRANSPORT_PASS_LIMIT = 2;
|
|
203
206
|
|
|
204
207
|
function defaultTerminatePoisonedProcess(tried: string): boolean {
|
|
@@ -206,6 +209,11 @@ function defaultTerminatePoisonedProcess(tried: string): boolean {
|
|
|
206
209
|
// CRTR_BROKER_PID with its own pid at boot. Any other host (tests, tooling)
|
|
207
210
|
// keeps the ordinary terminal-error path.
|
|
208
211
|
if (process.env["CRTR_BROKER_PID"] !== String(process.pid)) return false;
|
|
212
|
+
const routes = tried.split(', ').filter((route) => route !== '');
|
|
213
|
+
const nodeId = envNodeId();
|
|
214
|
+
if (nodeId !== undefined && routes.length > 0) {
|
|
215
|
+
recordTransportPoisonVerdict(nodeId, { consecutivePasses: POISONED_TRANSPORT_PASS_LIMIT, routes });
|
|
216
|
+
}
|
|
209
217
|
logRotation(
|
|
210
218
|
`process transport poisoned: ${POISONED_TRANSPORT_PASS_LIMIT} consecutive route-queue passes failed with no HTTP response (tried ${tried}); exiting so the daemon respawns this broker fresh`,
|
|
211
219
|
);
|
|
@@ -18,15 +18,10 @@
|
|
|
18
18
|
// explicitly replaced (the reconnect indicator, cleared by hand on redial).
|
|
19
19
|
// That timer is why this module has a `dispose`.
|
|
20
20
|
import { Text, truncateToWidth } from '@earendil-works/pi-tui';
|
|
21
|
-
import { oldestRunningBashJobStartedAtMs } from '../../../core/bash-jobs.js';
|
|
22
|
-
import { contextDir } from '../../../core/canvas/paths.js';
|
|
23
21
|
import { visibleWidth } from '../../../core/canvas/nav-render.js';
|
|
24
22
|
import { inTmux } from '../../../core/runtime/placement-tmux.js';
|
|
25
|
-
import {
|
|
23
|
+
import { formatBinding, inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
26
24
|
const NOTICE_CLEAR_MS = 3_000;
|
|
27
|
-
/** R4 (Alt+C -> b hint): how long a foreground bash command must have been
|
|
28
|
-
* running before backgrounding it is worth suggesting. */
|
|
29
|
-
const BASH_HINT_THRESHOLD_MS = 5_000;
|
|
30
25
|
const formatTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(n >= 10000 ? 0 : 1)}k` : `${n}`;
|
|
31
26
|
export function createStatusLine(s, hooks) {
|
|
32
27
|
const { pal, tui, nodeId, remote } = s;
|
|
@@ -81,22 +76,6 @@ export function createStatusLine(s, hooks) {
|
|
|
81
76
|
if (!hooks.mouseEnabled()) {
|
|
82
77
|
segs.push(pal.muted('mouse off'));
|
|
83
78
|
}
|
|
84
|
-
// Lowest-priority segment (R4): the Alt+C -> b hint, shown only once the
|
|
85
|
-
// oldest still-foreground bash job has crossed the threshold. Read straight
|
|
86
|
-
// off this node's job files rather than tracked from relayed tool-execution
|
|
87
|
-
// events — that on-disk state is the valve's own authoritative record of
|
|
88
|
-
// what is really running, so a fresh or reconnected attach (which never
|
|
89
|
-
// replays past events) reads it correctly with no viewer-local map that
|
|
90
|
-
// could go stale across a broker/session replacement. Evaluated per paint
|
|
91
|
-
// and on the viewer's existing 5s chrome poll — no new timer.
|
|
92
|
-
if (!remote && inTmux()) {
|
|
93
|
-
const oldestStart = oldestRunningBashJobStartedAtMs(contextDir(nodeId));
|
|
94
|
-
if (oldestStart !== undefined && Date.now() - oldestStart >= BASH_HINT_THRESHOLD_MS) {
|
|
95
|
-
const hint = backgroundBashHint(bindings);
|
|
96
|
-
if (hint !== null)
|
|
97
|
-
segs.push(pal.muted(`⧗ ${hint}`));
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
79
|
// The file-review affordance, tmux-only like the surface it opens.
|
|
101
80
|
if (inTmux() && bindings.gestures('crtr.attach.file-review').length > 0) {
|
|
102
81
|
segs.push(pal.muted(`${formatBinding(bindings, 'crtr.attach.file-review')} review file`));
|
|
@@ -58,6 +58,10 @@ export interface ChatViewOptions {
|
|
|
58
58
|
/** How the person opens their inbox on this surface, resolved lazily because
|
|
59
59
|
* bindings are resolved after ChatView is constructed. */
|
|
60
60
|
pageOpenHint?: () => string;
|
|
61
|
+
/** How the person hands a still-running bash command to the background on this
|
|
62
|
+
* surface, resolved lazily for the same reason. Null when this viewer cannot
|
|
63
|
+
* invoke the handoff at all, which suppresses the row. */
|
|
64
|
+
backgroundBashHint?: () => string | null;
|
|
61
65
|
/** Pre-colored banner lines pinned as the FIRST child of the chat container,
|
|
62
66
|
* above history (the crouton wordmark). Sits at the very top of the transcript
|
|
63
67
|
* and scrolls up into scrollback as the chat grows. Absent → no banner. */
|
|
@@ -85,6 +89,7 @@ export declare class ChatView {
|
|
|
85
89
|
private readonly pageBlocks;
|
|
86
90
|
private readonly inlinePageBlocks;
|
|
87
91
|
private readonly pageOpenHint;
|
|
92
|
+
private readonly backgroundBashHint;
|
|
88
93
|
/** Trailing cycles kept at full fidelity (config `live_cycles`). */
|
|
89
94
|
private readonly liveCycles;
|
|
90
95
|
/** Messages kept from a condensed cycle (config `condensed_history`). */
|
|
@@ -293,6 +298,12 @@ export declare class ChatView {
|
|
|
293
298
|
* way assistant prose stays. Condensed history is skipped deliberately — old
|
|
294
299
|
* cycles keep only prose and cycle seams. */
|
|
295
300
|
private maybeAppendPageBlock;
|
|
301
|
+
/** Re-measure the tool calls still in flight. A silent long-running command
|
|
302
|
+
* emits no tool-execution update, so nothing else would evict its cached
|
|
303
|
+
* lines and its backgrounding hint would never appear. Driven by the
|
|
304
|
+
* viewer's existing low-rate chrome poll, and cheap because it touches only
|
|
305
|
+
* in-flight tools — usually one, never the transcript. */
|
|
306
|
+
refreshInFlightTools(): void;
|
|
296
307
|
/** Re-read every placed page from disk. Driven by the viewer's ticket-activity
|
|
297
308
|
* watcher, so an answer, a cancellation, or a `--replace` revision lands on
|
|
298
309
|
* the block already anchored in the transcript. */
|
|
@@ -35,7 +35,7 @@ import { styleAttachAgentMarkdown, styleAttachMarkdownSource, styleAttachMessage
|
|
|
35
35
|
import { createCrtrEditToolDefinition } from './edit-diff.js';
|
|
36
36
|
import { GroupActivityRecorder } from './group-activity.js';
|
|
37
37
|
import { GroupRecapComponent } from './group-recap.js';
|
|
38
|
-
import { FoldedToolCallController, createCrtrPlainBashToolDefinition, createCrtrReadToolDefinition, createCrtrWriteToolDefinition, } from './tool-calls.js';
|
|
38
|
+
import { FoldedToolCallController, bashCallBackgroundHintLines, createCrtrPlainBashToolDefinition, createCrtrReadToolDefinition, createCrtrWriteToolDefinition, } from './tool-calls.js';
|
|
39
39
|
import { ringCompletionBell } from '../chrome/completion-bell.js';
|
|
40
40
|
import { extractFilePaths } from '#core/human/visible-paths';
|
|
41
41
|
import { keepWhenCondensed, liveRegionStart } from './condensed-history.js';
|
|
@@ -225,8 +225,13 @@ class MinimizableToolComponent extends ToolExecutionComponent {
|
|
|
225
225
|
super.setExpanded(this.expandedState);
|
|
226
226
|
}
|
|
227
227
|
render(width) {
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
// The backgrounding row is appended HERE, by the component that owns the
|
|
229
|
+
// whole tool block, so it renders after the block's highlighted band ends
|
|
230
|
+
// instead of inside it. It is empty unless this is a bash call still running
|
|
231
|
+
// past the threshold.
|
|
232
|
+
if (!this.minimized) {
|
|
233
|
+
return [...super.render(width), ...bashCallBackgroundHintLines(this.foldController?.callComponent(), width)];
|
|
234
|
+
}
|
|
230
235
|
const call = this.foldController?.callComponent();
|
|
231
236
|
if (!call)
|
|
232
237
|
return super.render(width);
|
|
@@ -276,6 +281,7 @@ export class ChatView {
|
|
|
276
281
|
pageBlocks = new Map();
|
|
277
282
|
inlinePageBlocks;
|
|
278
283
|
pageOpenHint;
|
|
284
|
+
backgroundBashHint;
|
|
279
285
|
/** Trailing cycles kept at full fidelity (config `live_cycles`). */
|
|
280
286
|
liveCycles;
|
|
281
287
|
/** Messages kept from a condensed cycle (config `condensed_history`). */
|
|
@@ -391,6 +397,7 @@ export class ChatView {
|
|
|
391
397
|
this.onActivityChange = opts.onActivityChange;
|
|
392
398
|
this.inlinePageBlocks = opts.inlinePageBlocks ?? false;
|
|
393
399
|
this.pageOpenHint = opts.pageOpenHint ?? (() => 'crtr human list');
|
|
400
|
+
this.backgroundBashHint = opts.backgroundBashHint ?? (() => null);
|
|
394
401
|
this.recapPalette = opts.palette ?? defaultAttachPalette;
|
|
395
402
|
this.spinnerStyle = this.recapPalette.active;
|
|
396
403
|
this.dimStyle = this.recapPalette.muted;
|
|
@@ -1380,7 +1387,7 @@ export class ChatView {
|
|
|
1380
1387
|
let resultObserver;
|
|
1381
1388
|
let toolDefinition;
|
|
1382
1389
|
if (name === 'bash') {
|
|
1383
|
-
toolDefinition = createCrtrBashToolDefinition(args, fold) ?? createCrtrPlainBashToolDefinition(this.cwd, fold);
|
|
1390
|
+
toolDefinition = createCrtrBashToolDefinition(args, fold) ?? createCrtrPlainBashToolDefinition(this.cwd, fold, this.backgroundBashHint);
|
|
1384
1391
|
}
|
|
1385
1392
|
else if (name === 'edit') {
|
|
1386
1393
|
const edit = createCrtrEditToolDefinition(this.cwd, fold);
|
|
@@ -1444,6 +1451,18 @@ export class ChatView {
|
|
|
1444
1451
|
this.pageBlocks.set(page.pageId, block);
|
|
1445
1452
|
this.append(block);
|
|
1446
1453
|
}
|
|
1454
|
+
/** Re-measure the tool calls still in flight. A silent long-running command
|
|
1455
|
+
* emits no tool-execution update, so nothing else would evict its cached
|
|
1456
|
+
* lines and its backgrounding hint would never appear. Driven by the
|
|
1457
|
+
* viewer's existing low-rate chrome poll, and cheap because it touches only
|
|
1458
|
+
* in-flight tools — usually one, never the transcript. */
|
|
1459
|
+
refreshInFlightTools() {
|
|
1460
|
+
if (this.pendingTools.size === 0)
|
|
1461
|
+
return;
|
|
1462
|
+
for (const component of this.pendingTools.values())
|
|
1463
|
+
this.historyContainer.markDirty(component);
|
|
1464
|
+
this.tui.requestRender();
|
|
1465
|
+
}
|
|
1447
1466
|
/** Re-read every placed page from disk. Driven by the viewer's ticket-activity
|
|
1448
1467
|
* watcher, so an answer, a cancellation, or a `--replace` revision lands on
|
|
1449
1468
|
* the block already anchored in the transcript. */
|
|
@@ -28,6 +28,11 @@ export declare function createCrtrReadToolDefinition(cwd: string, fold?: FoldedT
|
|
|
28
28
|
* syntax-highlighted content preview and its expand hint) with the icon
|
|
29
29
|
* prefixed onto its header line. Folded, only that header survives. */
|
|
30
30
|
export declare function createCrtrWriteToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
|
|
31
|
+
/** The backgrounding row for a tool component's captured call — nothing unless
|
|
32
|
+
* that call is a bash command still running past the threshold. The owning tool
|
|
33
|
+
* component renders it after its whole block, which is what keeps the row
|
|
34
|
+
* outside the highlighted band. */
|
|
35
|
+
export declare function bashCallBackgroundHintLines(call: Component | undefined, width: number): string[];
|
|
31
36
|
/** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
|
|
32
|
-
export declare function createCrtrPlainBashToolDefinition(cwd: string, fold?: FoldedToolCallController): unknown;
|
|
37
|
+
export declare function createCrtrPlainBashToolDefinition(cwd: string, fold?: FoldedToolCallController, backgroundHint?: () => string | null): unknown;
|
|
33
38
|
export {};
|
|
@@ -20,6 +20,22 @@ import { TOOL_ICON } from '../../../core/preview-registry.js';
|
|
|
20
20
|
function str(value) {
|
|
21
21
|
return typeof value === 'string' ? value : '';
|
|
22
22
|
}
|
|
23
|
+
/** How long a bash command must have been running before the transcript offers
|
|
24
|
+
* the backgrounding gesture under it. */
|
|
25
|
+
const BACKGROUND_HINT_AFTER_MS = 5_000;
|
|
26
|
+
/** The backgrounding affordance, under the running call it applies to, once the
|
|
27
|
+
* command has been running long enough that waiting it out is a real choice.
|
|
28
|
+
* Empty for a finished command, one short of the threshold, or a viewer that
|
|
29
|
+
* cannot invoke the handoff — the transcript is the user/assistant exchange,
|
|
30
|
+
* so this row exists only while it is actionable. */
|
|
31
|
+
function backgroundHintLines(theme, startedAt, hint, width) {
|
|
32
|
+
if (startedAt === undefined || Date.now() - startedAt < BACKGROUND_HINT_AFTER_MS)
|
|
33
|
+
return [];
|
|
34
|
+
const text = hint?.() ?? null;
|
|
35
|
+
if (text === null)
|
|
36
|
+
return [];
|
|
37
|
+
return [truncateToWidth(` ${theme.fg('muted', `⧗ ${text}`)}`, width, '…')];
|
|
38
|
+
}
|
|
23
39
|
/** Per-tool fold state owned by the attach viewer. Tool definitions capture the
|
|
24
40
|
* call component they render while folded, so the viewer never reaches into
|
|
25
41
|
* `ToolExecutionComponent`'s private renderer fields to recover it. */
|
|
@@ -236,16 +252,33 @@ class BashCallComponent {
|
|
|
236
252
|
folded = false;
|
|
237
253
|
timeout;
|
|
238
254
|
theme;
|
|
255
|
+
startedAt;
|
|
256
|
+
hint;
|
|
239
257
|
/** Nothing is cached across paints (render recomputes from the fields), so
|
|
240
258
|
* pi's cache-drop hook has nothing to do here. */
|
|
241
259
|
invalidate() { }
|
|
242
|
-
update(theme, command, purpose, expanded, folded, timeout) {
|
|
260
|
+
update(theme, command, purpose, expanded, folded, timeout, startedAt, hint) {
|
|
243
261
|
this.theme = theme;
|
|
244
262
|
this.command = command;
|
|
245
263
|
this.purpose = purpose;
|
|
246
264
|
this.expanded = expanded;
|
|
247
265
|
this.folded = folded;
|
|
248
266
|
this.timeout = timeout;
|
|
267
|
+
this.startedAt = startedAt;
|
|
268
|
+
this.hint = hint;
|
|
269
|
+
}
|
|
270
|
+
/** The backgrounding affordance, under the call it applies to, once this
|
|
271
|
+
* command has been running long enough that waiting it out is a real choice.
|
|
272
|
+
* Empty for a command that has finished, one that has not reached the
|
|
273
|
+
* threshold, or a viewer that cannot invoke the handoff — the transcript is
|
|
274
|
+
* the user/assistant exchange, so this row exists only while it is
|
|
275
|
+
* actionable. */
|
|
276
|
+
/** The backgrounding row for this call. Rendered by the tool component that
|
|
277
|
+
* OWNS this call, after the whole block, so it lands outside the highlighted
|
|
278
|
+
* band the block paints rather than inside it. */
|
|
279
|
+
backgroundHintLines(width) {
|
|
280
|
+
const theme = this.theme;
|
|
281
|
+
return theme === undefined ? [] : backgroundHintLines(theme, this.startedAt, this.hint, width);
|
|
249
282
|
}
|
|
250
283
|
render(width) {
|
|
251
284
|
const theme = this.theme;
|
|
@@ -286,8 +319,15 @@ class BashCallComponent {
|
|
|
286
319
|
return [truncateToWidth(`${icon} ${styleCommandLine(shown, theme)}${more}${suffix}`, width)];
|
|
287
320
|
}
|
|
288
321
|
}
|
|
322
|
+
/** The backgrounding row for a tool component's captured call — nothing unless
|
|
323
|
+
* that call is a bash command still running past the threshold. The owning tool
|
|
324
|
+
* component renders it after its whole block, which is what keeps the row
|
|
325
|
+
* outside the highlighted band. */
|
|
326
|
+
export function bashCallBackgroundHintLines(call, width) {
|
|
327
|
+
return call instanceof BashCallComponent ? call.backgroundHintLines(width) : [];
|
|
328
|
+
}
|
|
289
329
|
/** Attach-viewer `bash` for anything that is not a `crtr` invocation. */
|
|
290
|
-
export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCallController()) {
|
|
330
|
+
export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCallController(), backgroundHint) {
|
|
291
331
|
const builtin = createBashToolDefinition(cwd);
|
|
292
332
|
return {
|
|
293
333
|
name: 'bash',
|
|
@@ -305,12 +345,16 @@ export function createCrtrPlainBashToolDefinition(cwd, fold = new FoldedToolCall
|
|
|
305
345
|
state.endedAt = undefined;
|
|
306
346
|
}
|
|
307
347
|
const component = context.lastComponent instanceof BashCallComponent ? context.lastComponent : new BashCallComponent();
|
|
348
|
+
// Running = execution started and no result has stamped `endedAt`. The
|
|
349
|
+
// hint row lives on that fact, so it disappears the moment the command
|
|
350
|
+
// returns without anything having to clear it.
|
|
351
|
+
const running = state?.startedAt !== undefined && state.endedAt === undefined;
|
|
308
352
|
component.update(theme, str(a?.command),
|
|
309
353
|
// The purpose stands in for the command only while folded; it is normalized
|
|
310
354
|
// here because it is model-supplied text painted straight into the frame.
|
|
311
355
|
fold.folded ? normalizeBashJobPurpose(a?.purpose) ?? undefined : undefined,
|
|
312
356
|
// Folded wins over ctrl+o expansion: a folded bash is always its one-line form.
|
|
313
|
-
Boolean(context.expanded) && !fold.folded, fold.folded, typeof a?.timeout === 'number' ? a.timeout : undefined);
|
|
357
|
+
Boolean(context.expanded) && !fold.folded, fold.folded, typeof a?.timeout === 'number' ? a.timeout : undefined, running ? state?.startedAt : undefined, backgroundHint);
|
|
314
358
|
return fold.capture(component);
|
|
315
359
|
},
|
|
316
360
|
renderResult(result, options, theme, context) {
|
|
@@ -5,7 +5,7 @@ export type AttachActions = Record<AttachBindingId, () => void>;
|
|
|
5
5
|
export declare const ATTACH_VIEWPORT_BINDINGS: readonly ["crtr.attach.scroll.up", "crtr.attach.scroll.down", "crtr.attach.scroll.page-up", "crtr.attach.scroll.page-down", "crtr.attach.scroll.live", "crtr.attach.mouse.toggle", "crtr.attach.copy.message"];
|
|
6
6
|
export type AttachViewportBindingId = typeof ATTACH_VIEWPORT_BINDINGS[number];
|
|
7
7
|
export type AttachViewportActions = Record<AttachViewportBindingId, () => void>;
|
|
8
|
-
export { inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
8
|
+
export { backgroundBashHint, inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
9
9
|
export declare function matchesAttachBinding(bindings: AttachBindings, id: BindingId, data: string): boolean;
|
|
10
10
|
export declare function dispatchAttachAction(id: AttachBindingId, actions: AttachActions): void;
|
|
11
11
|
export declare function dispatchAttachBinding(bindings: AttachBindings, data: string, actions: AttachActions): boolean;
|
|
@@ -13,7 +13,7 @@ export const ATTACH_VIEWPORT_BINDINGS = [
|
|
|
13
13
|
'crtr.attach.mouse.toggle',
|
|
14
14
|
'crtr.attach.copy.message',
|
|
15
15
|
];
|
|
16
|
-
export { inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
16
|
+
export { backgroundBashHint, inboxOpenHint } from '../../../core/keybindings/index.js';
|
|
17
17
|
export function matchesAttachBinding(bindings, id, data) {
|
|
18
18
|
// Match the normalized chunk, never the raw one: a terminal that sends Option
|
|
19
19
|
// as a meta prefix spells Alt+Shift+M in a form pi-tui rejects. Only matching
|
|
@@ -13,6 +13,9 @@ export interface ChromeRefreshHooks {
|
|
|
13
13
|
refreshIdentity: () => void;
|
|
14
14
|
renderFooter: () => void;
|
|
15
15
|
renderExtensionWidgets: () => void;
|
|
16
|
+
/** Re-measure the tool calls still in flight, so a silent long-running bash
|
|
17
|
+
* call still reaches its backgrounding hint with no per-call timer. */
|
|
18
|
+
refreshInFlightTools: () => void;
|
|
16
19
|
/** Re-poll this pane's own git context (owned by the editor chrome). */
|
|
17
20
|
pollGit: () => void;
|
|
18
21
|
}
|
|
@@ -62,9 +62,9 @@ export function createChromeRefresh(ctx, hooks) {
|
|
|
62
62
|
hooks.refreshIdentity();
|
|
63
63
|
hooks.renderExtensionWidgets();
|
|
64
64
|
hooks.pollGit();
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
65
|
+
// A long-running SILENT bash call emits no tool-execution update, so this
|
|
66
|
+
// cadence is what brings its backgrounding hint on screen.
|
|
67
|
+
hooks.refreshInFlightTools();
|
|
68
68
|
hooks.renderFooter();
|
|
69
69
|
void (async () => {
|
|
70
70
|
const root = await climbRootFromSource(nodeId);
|