@aragon-agent/cli 0.6.11 → 0.6.13
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 +10 -1
- package/README.md +97 -20
- package/dist/agent/controller.d.ts +38 -3
- package/dist/agent/controller.js +151 -60
- package/dist/agent/model-profile-settings.d.ts +31 -0
- package/dist/agent/model-profile-settings.js +199 -0
- package/dist/agent/queued-messages.d.ts +20 -0
- package/dist/agent/queued-messages.js +46 -0
- package/dist/agent/reducer.d.ts +21 -2
- package/dist/agent/reducer.js +65 -37
- package/dist/cli.js +15 -1
- package/dist/commands/builtins.js +83 -42
- package/dist/commands/queue-command.d.ts +3 -0
- package/dist/commands/queue-command.js +5 -0
- package/dist/commands/registry.d.ts +3 -0
- package/dist/compaction/child.d.ts +4 -2
- package/dist/compaction/child.js +4 -1
- package/dist/compaction/compactor.d.ts +7 -2
- package/dist/compaction/compactor.js +13 -11
- package/dist/compaction/context-command.js +5 -0
- package/dist/compaction/wiring.d.ts +4 -3
- package/dist/compaction/wiring.js +6 -1
- package/dist/config/cli-commands.d.ts +2 -0
- package/dist/config/cli-commands.js +16 -4
- package/dist/config/load.d.ts +2 -1
- package/dist/config/load.js +15 -8
- package/dist/config/model-profile-resolution.d.ts +20 -0
- package/dist/config/model-profile-resolution.js +81 -0
- package/dist/config/model-profile-store.d.ts +67 -0
- package/dist/config/model-profile-store.js +153 -0
- package/dist/config/model-profiles.d.ts +44 -0
- package/dist/config/model-profiles.js +121 -0
- package/dist/config/schema.d.ts +9 -0
- package/dist/config/store.d.ts +11 -1
- package/dist/config/store.js +43 -27
- package/dist/fast/resolve.d.ts +2 -1
- package/dist/fast/resolve.js +6 -4
- package/dist/fast/wiring.d.ts +5 -3
- package/dist/fast/wiring.js +8 -4
- package/dist/input/enter-sequences.d.ts +11 -10
- package/dist/input/enter-sequences.js +29 -27
- package/dist/input/stdin-filter.js +93 -28
- package/dist/logging/install.d.ts +3 -0
- package/dist/logging/install.js +4 -2
- package/dist/logging/secret-registry.d.ts +2 -0
- package/dist/logging/secret-registry.js +18 -0
- package/dist/session/persist.d.ts +6 -7
- package/dist/session/persist.js +6 -9
- package/dist/session/store.d.ts +4 -1
- package/dist/session/validate-session.d.ts +3 -0
- package/dist/session/validate-session.js +207 -0
- package/dist/team/runtime.d.ts +2 -1
- package/dist/team/subagent.d.ts +3 -1
- package/dist/team/subagent.js +3 -1
- package/dist/ui/ActivityLine.d.ts +6 -70
- package/dist/ui/ActivityLine.js +12 -34
- package/dist/ui/App.d.ts +1 -0
- package/dist/ui/App.js +161 -226
- package/dist/ui/BottomStatusRow.d.ts +6 -86
- package/dist/ui/BottomStatusRow.js +11 -26
- package/dist/ui/Composer.d.ts +18 -84
- package/dist/ui/Composer.js +9 -100
- package/dist/ui/PromptInput.d.ts +6 -2
- package/dist/ui/PromptInput.js +91 -105
- package/dist/ui/QueuePanel.d.ts +8 -0
- package/dist/ui/QueuePanel.js +9 -0
- package/dist/ui/QueueStatusRow.d.ts +24 -0
- package/dist/ui/QueueStatusRow.js +51 -0
- package/dist/ui/StatusBar.d.ts +15 -149
- package/dist/ui/StatusBar.js +27 -112
- package/dist/ui/clipboard-task.d.ts +29 -0
- package/dist/ui/clipboard-task.js +66 -0
- package/dist/ui/clipboard.d.ts +29 -50
- package/dist/ui/clipboard.js +141 -75
- package/dist/ui/composer-input.d.ts +28 -0
- package/dist/ui/composer-input.js +46 -0
- package/dist/ui/composer-limits.d.ts +3 -4
- package/dist/ui/composer-limits.js +4 -9
- package/dist/ui/composer-rows.d.ts +1 -1
- package/dist/ui/composer-rows.js +24 -137
- package/dist/ui/editor-navigation.d.ts +45 -0
- package/dist/ui/editor-navigation.js +85 -0
- package/dist/ui/editor-reducer.d.ts +9 -0
- package/dist/ui/editor-reducer.js +31 -15
- package/dist/ui/enter-frames.d.ts +15 -31
- package/dist/ui/enter-frames.js +14 -33
- package/dist/ui/interaction-copy.d.ts +77 -0
- package/dist/ui/interaction-copy.js +40 -0
- package/dist/ui/interaction-hints.d.ts +18 -0
- package/dist/ui/interaction-hints.js +92 -0
- package/dist/ui/layout/budget.js +1 -1
- package/dist/ui/layout/queue-layout.d.ts +19 -0
- package/dist/ui/layout/queue-layout.js +26 -0
- package/dist/ui/layout/status-layout.d.ts +55 -0
- package/dist/ui/layout/status-layout.js +109 -0
- package/dist/ui/layout/todo-layout.d.ts +1 -0
- package/dist/ui/layout/todo-layout.js +1 -1
- package/dist/ui/overlays/HelpOverlay.js +11 -3
- package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
- package/dist/ui/overlays/ModelProfileEditor.js +103 -0
- package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
- package/dist/ui/overlays/ModelProfilePicker.js +167 -0
- package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
- package/dist/ui/overlays/QueueOverlay.js +79 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
- package/dist/ui/overlays/SettingsScreen.js +347 -3
- package/dist/ui/overlays/model-profile-state.d.ts +74 -0
- package/dist/ui/overlays/model-profile-state.js +119 -0
- package/dist/ui/run-status-row.d.ts +7 -19
- package/dist/ui/run-status-row.js +2 -18
- package/dist/ui/selection/selection-controller.d.ts +2 -2
- package/dist/ui/selection/selection-controller.js +58 -20
- package/dist/ui/transcript-text.js +20 -12
- package/dist/ui/use-wheel-routing.js +2 -1
- package/package.json +2 -2
package/dist/ui/StatusBar.js
CHANGED
|
@@ -1,118 +1,33 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** A single row of complete, measured status fields. */
|
|
3
|
+
import React from 'react';
|
|
3
4
|
import { Box, Text, useStdout } from 'ink';
|
|
4
5
|
import { pickGlyphs } from './glyphs.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { FAST_LIMITS } from '../fast/limits.js';
|
|
10
|
-
import { COMPACTION_LIMITS } from '../compaction/limits.js';
|
|
11
|
-
import { TEAM_LIMITS } from '../team/limits.js';
|
|
12
|
-
import { TODO_LIMITS } from '../todo/limits.js';
|
|
13
|
-
import { PROC_LIMITS } from '../proc/limits.js';
|
|
14
|
-
/** Below this the `eco` chip costs the context gauge more than it is worth. */
|
|
15
|
-
const ECO_MIN_COLS = 72;
|
|
16
|
-
/**
|
|
17
|
-
* Where `86k/200k` starts being shown (context-usage-gauge-accuracy §3.6 / W5).
|
|
18
|
-
*
|
|
19
|
-
* IT TAKES THE COLUMNS THE SESSION TOTAL USED TO HAVE, and the ordering is the
|
|
20
|
-
* point rather than a layout accident (D-6). "How full am I, out of how much" is
|
|
21
|
-
* the question a user asks the gauge; "what has this session cost in tokens" is
|
|
22
|
-
* a different one, and putting the second in front of the first spends the
|
|
23
|
-
* scarce columns on the number the user is more likely to MISREAD - the total
|
|
24
|
-
* looks like an occupancy until you notice it exceeds the window.
|
|
25
|
-
*/
|
|
26
|
-
const ABSOLUTE_PAIR_MIN_COLS = 72;
|
|
27
|
-
/**
|
|
28
|
-
* Where the session total returns, now prefixed.
|
|
29
|
-
*
|
|
30
|
-
* `total ` IS AN ASCII WORD, NOT A SIGMA. `ui/**` is inside the glyph scanner's
|
|
31
|
-
* scope, so a new symbol would need an entry in `glyphs.ts` with an ASCII
|
|
32
|
-
* fallback for a legacy `cmd.exe` - a real cost for a three-character prefix
|
|
33
|
-
* that reads worse.
|
|
34
|
-
*/
|
|
35
|
-
const SESSION_TOTAL_MIN_COLS = 96;
|
|
36
|
-
/**
|
|
37
|
-
* I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
|
|
38
|
-
* gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
|
|
39
|
-
* output has not changed is never repainted. `Ctrl+L` therefore cannot work by
|
|
40
|
-
* writing an escape sequence; it works by making the rendered STRING differ.
|
|
41
|
-
* This alternates the status bar's left gutter between U+0020 and U+00A0: both
|
|
42
|
-
* render one invisible column, so there is zero visual or layout difference,
|
|
43
|
-
* but the bytes differ and both gates open.
|
|
44
|
-
*
|
|
45
|
-
* It must sit at the START of the line: Ink `trimEnd()`s every row (output.js),
|
|
46
|
-
* and JS `trimEnd` strips U+00A0 too — a trailing carrier would be deleted and
|
|
47
|
-
* Ctrl+L would degrade to a silent no-op.
|
|
48
|
-
*/
|
|
49
|
-
/** Built by code point so no formatter can normalize it back to a plain space. */
|
|
50
|
-
const NBSP = String.fromCharCode(0x00a0);
|
|
51
|
-
function redrawChar(nonce) {
|
|
52
|
-
return nonce % 2 === 0 ? ' ' : NBSP;
|
|
53
|
-
}
|
|
6
|
+
import { liveSpinner } from './ActivityLine.js';
|
|
7
|
+
import { planStatusFields } from './layout/status-layout.js';
|
|
8
|
+
/** An invisible leading byte change forces Ink to repaint on Ctrl+L. */
|
|
9
|
+
const redrawChar = (nonce) => nonce % 2 ? String.fromCharCode(0x00a0) : ' ';
|
|
54
10
|
export function StatusBar(props) {
|
|
55
|
-
const { model, provider, usageTotal, context, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, servicesActive, retryActive, ecoRung = 0, fastActive, compactionActive, gaugeMarks, } = props;
|
|
56
11
|
const { stdout } = useStdout();
|
|
57
12
|
const cols = stdout?.columns ?? 80;
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const pctLabel = `${context.windowKnown && !occupancyApproximate ? '' : '~'}${gauge.pct}%`;
|
|
79
|
-
// `?` ON THE WINDOW, AND ONLY ON THE NEW READOUT. `windowKnown` is false
|
|
80
|
-
// exactly when `buildRuntimeModel` invented the 128k placeholder, and saying
|
|
81
|
-
// `128k?` is the difference between a denominator the user can act on and one
|
|
82
|
-
// they will assume came from their model card.
|
|
83
|
-
const windowLabel = `${formatTokens(context.window)}${context.windowKnown ? '' : '?'}`;
|
|
84
|
-
const absolutePair = `${formatTokens(context.occupied)}/${windowLabel}`;
|
|
85
|
-
// THE PROMPT-SIDE TOTAL, NOT `inputTokens` (P1-3 / AC-11). Anthropic reports
|
|
86
|
-
// `input_tokens` EXCLUDING cached tokens, so the old expression under-reported
|
|
87
|
-
// for anyone behind a caching gateway - on the same row as a `$` that has
|
|
88
|
-
// always priced the cache. Any session with cache hits sees this number grow,
|
|
89
|
-
// and that is the fix rather than a regression.
|
|
90
|
-
const tokens = `total ${formatTokens(promptTokensOf(usageTotal))}${glyphs.arrowUp} ${formatTokens(usageTotal.outputTokens)}${glyphs.arrowDown}`;
|
|
91
|
-
const statusGlyph = running ? theme.symbols.toolRunning : theme.symbols.toolPending;
|
|
92
|
-
// Nothing at all in build mode with nothing pending: the default posture is
|
|
93
|
-
// not news, and a permanent word here would be furniture.
|
|
94
|
-
const modeWord = agentMode === 'build' && !pendingAgentMode
|
|
95
|
-
? ''
|
|
96
|
-
: pendingAgentMode
|
|
97
|
-
? `${MODE_LABEL[agentMode]} ${glyphs.arrowRight} ${MODE_LABEL[pendingAgentMode]}`
|
|
98
|
-
: MODE_LABEL[agentMode];
|
|
99
|
-
const actionHints = [running ? (props.interruptHint ?? 'Esc abort') : '',
|
|
100
|
-
servicesActive ? `Ctrl+C stop ${servicesActive.live}` : '',
|
|
101
|
-
scrolledLines > 0 ? 'PgDn down' : ''].filter(Boolean).join(' | ');
|
|
102
|
-
const hintCols = actionHints ? Math.min(Math.max(0, cols - 1), stringWidth(actionHints) + 2) : 0;
|
|
103
|
-
const detailCols = Math.max(0, cols - 1 - hintCols);
|
|
104
|
-
return (
|
|
105
|
-
// `flexShrink={0}` on the left cluster is not cosmetic. When the two clusters
|
|
106
|
-
// over-subscribe the row, yoga shrinks the flexible children and Ink's text
|
|
107
|
-
// measurement drops a character from EACH of them — the bar renders "idl"
|
|
108
|
-
// and "? hel" rather than truncating cleanly at one end. Pinning the left
|
|
109
|
-
// cluster keeps the run status and model intact and makes the degradation
|
|
110
|
-
// land in one predictable place.
|
|
111
|
-
_jsxs(Box, { width: cols, flexDirection: "row", justifyContent: "space-between", flexShrink: 0, height: 1, overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(redrawNonce) }) }), hintCols > 0 && (_jsx(Box, { width: hintCols, flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [actionHints, ' '] }) })), _jsxs(Box, { display: detailCols > 0 ? 'flex' : 'none', width: detailCols, flexShrink: 0, flexDirection: "row", justifyContent: "space-between", overflow: "hidden", children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, overflow: "hidden", children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
|
|
112
|
-
? `agents ${teamActive.running}/${teamActive.total}`
|
|
113
|
-
: `[${teamActive.running}]`] })), retryActive && (_jsxs(Text, { color: theme.noticeWarn, bold: true, children: [' ', formatRetryChip(retryActive, cols)] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
|
|
114
|
-
? `todo ${todoActive.done}/${todoActive.total}`
|
|
115
|
-
: `[${todoActive.done}/${todoActive.total}]`] })), servicesActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= PROC_LIMITS.statusCompactCols
|
|
116
|
-
? `svc ${servicesActive.live}`
|
|
117
|
-
: `[${servicesActive.live}]`] })), cols >= 60 && (cols >= 110 || (!running && !servicesActive && scrolledLines === 0)) && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [compactionActive && cols >= COMPACTION_LIMITS.statusCompactCols && (_jsxs(Text, { color: compactionActive.inFlight ? theme.accent : theme.muted, children: [compactionActive.inFlight ? 'compacting' : 'compact', ' '] })), fastActive && cols >= FAST_LIMITS.statusCompactCols && (_jsxs(Text, { color: theme.muted, children: ["fast", fastActive.inFlight ? '*' : '', ' '] })), ecoRung > 0 && cols >= ECO_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: ["eco", ' '] })), scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= ABSOLUTE_PAIR_MIN_COLS && (_jsxs(Text, { color: theme.muted, children: [" ", absolutePair] })), cols >= SESSION_TOTAL_MIN_COLS && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] })] }));
|
|
13
|
+
const phase = props.runPhase ?? (props.status === 'running' ? 'waiting' : 'idle');
|
|
14
|
+
const active = phase !== 'idle' || !!props.compactionActive?.inFlight;
|
|
15
|
+
const glyph = pickGlyphs(props.caps).spinnerStill;
|
|
16
|
+
const spinner = active ? liveSpinner(props.reducedMotion ?? false, props.caps) : null;
|
|
17
|
+
const fields = planStatusFields({
|
|
18
|
+
columns: cols, phase, pendingCount: props.pendingSteering?.length ?? 0,
|
|
19
|
+
context: props.context, usageTotal: props.usageTotal,
|
|
20
|
+
thinkingLevel: props.thinkingLevel, elapsedMs: props.elapsedMs, tokPerSec: props.tokPerSec,
|
|
21
|
+
activeTool: props.activeTool, runOutcome: props.runOutcome, spinner: active ? glyph : '',
|
|
22
|
+
waitingForConfirmation: props.waitingForConfirmation, stopping: props.stopping,
|
|
23
|
+
compacting: props.compactionActive?.inFlight, retryActive: props.retryActive,
|
|
24
|
+
mode: props.agentMode, pendingMode: props.pendingAgentMode, servicesActive: props.servicesActive,
|
|
25
|
+
teamActive: props.teamActive, todoActive: props.todoActive,
|
|
26
|
+
ecoRung: props.ecoRung, fastActive: props.fastActive, scrolledLines: props.scrolledLines,
|
|
27
|
+
});
|
|
28
|
+
const tones = { normal: props.theme.primary, muted: props.theme.muted,
|
|
29
|
+
warning: props.theme.noticeWarn, error: props.theme.noticeError };
|
|
30
|
+
return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: 3, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: " | " }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], children: item.id === 'phase' && active
|
|
31
|
+
? _jsxs(_Fragment, { children: [spinner ?? glyph, item.text.slice(glyph.length)] })
|
|
32
|
+
: item.text }) })] }, item.id))] });
|
|
118
33
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ClipboardOptions, type CopyResult } from './clipboard.js';
|
|
2
|
+
export interface CopyRequest {
|
|
3
|
+
text: string;
|
|
4
|
+
lines: number;
|
|
5
|
+
}
|
|
6
|
+
export type CopyRequestResult = CopyResult | {
|
|
7
|
+
status: 'busy';
|
|
8
|
+
};
|
|
9
|
+
export interface ClipboardState {
|
|
10
|
+
busy: boolean;
|
|
11
|
+
cleanupPending: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ClipboardCoordinatorOptions {
|
|
14
|
+
clipboard?: ClipboardOptions;
|
|
15
|
+
/** Clears the previous Ctrl+C exit preparation for both copy entry points. */
|
|
16
|
+
onRequest?: () => void;
|
|
17
|
+
/** A repeated request is consumed; its selection must remain untouched. */
|
|
18
|
+
onBusy?: (state: ClipboardState) => void;
|
|
19
|
+
onResult?: (result: CopyResult, request: CopyRequest) => void;
|
|
20
|
+
onStateChange?: (state: ClipboardState) => void;
|
|
21
|
+
}
|
|
22
|
+
export interface ClipboardCoordinator {
|
|
23
|
+
requestCopy(request: CopyRequest): Promise<CopyRequestResult>;
|
|
24
|
+
isBusy(): boolean;
|
|
25
|
+
isCleanupPending(): boolean;
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
|
28
|
+
/** Create the App-owned copy port; use its live busy state before taking a selection. */
|
|
29
|
+
export declare function createClipboardCoordinator(options?: ClipboardCoordinatorOptions): ClipboardCoordinator;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { startClipboardTask, } from './clipboard.js';
|
|
2
|
+
class SharedClipboardCoordinator {
|
|
3
|
+
options;
|
|
4
|
+
busy = false;
|
|
5
|
+
cleanupPending = false;
|
|
6
|
+
disposed = false;
|
|
7
|
+
abort = new AbortController();
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
options.clipboard?.signal?.addEventListener('abort', this.dispose, { once: true });
|
|
11
|
+
if (options.clipboard?.signal?.aborted)
|
|
12
|
+
this.dispose();
|
|
13
|
+
}
|
|
14
|
+
isBusy() { return this.busy; }
|
|
15
|
+
isCleanupPending() { return this.cleanupPending; }
|
|
16
|
+
state() {
|
|
17
|
+
return { busy: this.busy, cleanupPending: this.cleanupPending };
|
|
18
|
+
}
|
|
19
|
+
notifyState() {
|
|
20
|
+
if (!this.disposed)
|
|
21
|
+
this.options.onStateChange?.(this.state());
|
|
22
|
+
}
|
|
23
|
+
requestCopy = (request) => {
|
|
24
|
+
if (this.disposed)
|
|
25
|
+
return Promise.resolve({ status: 'failed', reason: 'cancelled' });
|
|
26
|
+
if (this.busy) {
|
|
27
|
+
this.options.onRequest?.();
|
|
28
|
+
this.options.onBusy?.(this.state());
|
|
29
|
+
return Promise.resolve({ status: 'busy' });
|
|
30
|
+
}
|
|
31
|
+
// Claim synchronously, before callbacks or Promise work can re-enter this port.
|
|
32
|
+
this.busy = true;
|
|
33
|
+
this.options.onRequest?.();
|
|
34
|
+
this.notifyState();
|
|
35
|
+
const snapshot = { ...request };
|
|
36
|
+
const task = startClipboardTask(snapshot.text, {
|
|
37
|
+
...this.options.clipboard, signal: this.abort.signal,
|
|
38
|
+
});
|
|
39
|
+
const result = task.result.then((value) => {
|
|
40
|
+
if (!this.disposed) {
|
|
41
|
+
this.cleanupPending = this.busy && value.status === 'failed';
|
|
42
|
+
this.notifyState();
|
|
43
|
+
this.options.onResult?.(value, snapshot);
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
});
|
|
47
|
+
// Failure feedback is not a release signal: the old native writer may still live.
|
|
48
|
+
void task.released.then(() => {
|
|
49
|
+
this.busy = false;
|
|
50
|
+
this.cleanupPending = false;
|
|
51
|
+
this.notifyState();
|
|
52
|
+
});
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
dispose = () => {
|
|
56
|
+
if (this.disposed)
|
|
57
|
+
return;
|
|
58
|
+
this.disposed = true;
|
|
59
|
+
this.options.clipboard?.signal?.removeEventListener('abort', this.dispose);
|
|
60
|
+
this.abort.abort();
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Create the App-owned copy port; use its live busy state before taking a selection. */
|
|
64
|
+
export function createClipboardCoordinator(options = {}) {
|
|
65
|
+
return new SharedClipboardCoordinator(options);
|
|
66
|
+
}
|
package/dist/ui/clipboard.d.ts
CHANGED
|
@@ -1,52 +1,31 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* `writeForeign` instead, and this module cannot pick the wrong door.
|
|
26
|
-
*/
|
|
27
|
-
export type CopyVia = 'osc52' | 'native' | 'none';
|
|
28
|
-
/**
|
|
29
|
-
* Ceiling on an OSC 52 payload, in bytes of the ORIGINAL text.
|
|
30
|
-
*
|
|
31
|
-
* xterm's default limit is around 100 000 BASE64 characters and tmux / screen
|
|
32
|
-
* are stricter, so 56 000 source bytes (~74 700 base64) stays inside all three
|
|
33
|
-
* with room to spare. Past it the sequence is skipped rather than truncated: a
|
|
34
|
-
* terminal that drops an over-long OSC 52 leaves the clipboard holding whatever
|
|
35
|
-
* was there before, and half a selection is worse than none.
|
|
36
|
-
*/
|
|
1
|
+
export type CopyFailureReason = 'empty' | 'unavailable' | 'write' | 'timeout' | 'too-large' | 'cancelled';
|
|
2
|
+
export type CopyResult = {
|
|
3
|
+
status: 'confirmed';
|
|
4
|
+
via: 'native';
|
|
5
|
+
} | {
|
|
6
|
+
status: 'sent';
|
|
7
|
+
via: 'osc52';
|
|
8
|
+
fallbackReason?: CopyFailureReason;
|
|
9
|
+
} | {
|
|
10
|
+
status: 'failed';
|
|
11
|
+
reason: CopyFailureReason;
|
|
12
|
+
};
|
|
13
|
+
export interface ClipboardOptions {
|
|
14
|
+
/** Frame differ's foreign-write door, never the intercepted stdout stream. */
|
|
15
|
+
write?: (chunk: string) => void;
|
|
16
|
+
remote?: boolean;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export interface ClipboardTask {
|
|
21
|
+
result: Promise<CopyResult>;
|
|
22
|
+
/** Resolves only after no writer can change the clipboard again. */
|
|
23
|
+
released: Promise<void>;
|
|
24
|
+
}
|
|
37
25
|
export declare const MAX_OSC52_BYTES = 56000;
|
|
38
|
-
/**
|
|
26
|
+
/** Encode the full clipboard selection as UTF-8, without truncating its payload. */
|
|
39
27
|
export declare function osc52(text: string): string;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
* is skipped entirely, which is the correct behaviour for a caller that has no
|
|
45
|
-
* terminal to write to.
|
|
46
|
-
*
|
|
47
|
-
* NEITHER MECHANISM IS DETECTABLE. `spawn` returning without throwing does not
|
|
48
|
-
* mean `xclip` exists, and an OSC 52 sequence a terminal ignores is
|
|
49
|
-
* indistinguishable from one it honours. The return value therefore names what
|
|
50
|
-
* was ATTEMPTED, and every caller's user-facing text is worded accordingly.
|
|
51
|
-
*/
|
|
52
|
-
export declare function copyText(text: string, write?: (chunk: string) => void): CopyVia;
|
|
28
|
+
/** Start a copy without rejecting; result and writer release are separate signals. */
|
|
29
|
+
export declare function startClipboardTask(text: string, options?: ClipboardOptions): ClipboardTask;
|
|
30
|
+
/** Promise convenience wrapper; interactive callers must use the shared coordinator. */
|
|
31
|
+
export declare function copyText(text: string, options?: ClipboardOptions): Promise<CopyResult>;
|
package/dist/ui/clipboard.js
CHANGED
|
@@ -1,91 +1,157 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clipboard (tui-selection-and-scroll-follow §4.4.5) — the SINGLE clipboard path
|
|
3
|
-
* for the package. `/copy` and the selection release both come through here.
|
|
4
|
-
*
|
|
5
|
-
* Two mechanisms, both attempted, and the return value names the better one so
|
|
6
|
-
* the toast can say what was SENT rather than claiming a success neither one can
|
|
7
|
-
* confirm:
|
|
8
|
-
*
|
|
9
|
-
* 1. **OSC 52.** It reaches the clipboard of the machine the USER is sitting
|
|
10
|
-
* at, which is the only correct target over SSH, and it is the only mechanism
|
|
11
|
-
* that works with no helper binary installed. `/copy` gained SSH support the
|
|
12
|
-
* moment it moved onto this module.
|
|
13
|
-
* 2. **The platform binary** (`clip` / `pbcopy` / `xclip`), moved here unchanged
|
|
14
|
-
* from `commands/builtins.ts`, for the local case and for terminals that
|
|
15
|
-
* ignore OSC 52 (tmux without `set -g set-clipboard on`, R-7).
|
|
16
|
-
*
|
|
17
|
-
* IT TAKES A WRITE *DOOR*, NOT A STREAM, and that is P1-6 rather than taste. In
|
|
18
|
-
* full-screen mode stdout is a `Proxy` in front of the frame differ; an
|
|
19
|
-
* unrecognised chunk there takes `passThrough(true)`, which increments
|
|
20
|
-
* `fallbacks` and — on the first one — raises `onFirstFallback`, wired to
|
|
21
|
-
* `console.warn(FRAME_FALLBACK_NOTICE)`. Sending OSC 52 through the proxy would
|
|
22
|
-
* therefore print a diagnostic at a user who did nothing wrong the first time
|
|
23
|
-
* they copied anything, and would permanently corrupt a counter documented to
|
|
24
|
-
* mean "something wrote to stdout behind Ink's back". The caller supplies
|
|
25
|
-
* `writeForeign` instead, and this module cannot pick the wrong door.
|
|
26
|
-
*/
|
|
1
|
+
/** The only clipboard writer. Interaction callers share clipboard-task's coordinator. */
|
|
27
2
|
import { spawn } from 'node:child_process';
|
|
28
3
|
import process from 'node:process';
|
|
29
|
-
/**
|
|
30
|
-
* Ceiling on an OSC 52 payload, in bytes of the ORIGINAL text.
|
|
31
|
-
*
|
|
32
|
-
* xterm's default limit is around 100 000 BASE64 characters and tmux / screen
|
|
33
|
-
* are stricter, so 56 000 source bytes (~74 700 base64) stays inside all three
|
|
34
|
-
* with room to spare. Past it the sequence is skipped rather than truncated: a
|
|
35
|
-
* terminal that drops an over-long OSC 52 leaves the clipboard holding whatever
|
|
36
|
-
* was there before, and half a selection is worse than none.
|
|
37
|
-
*/
|
|
38
4
|
export const MAX_OSC52_BYTES = 56_000;
|
|
39
|
-
|
|
5
|
+
const CLEANUP_GRACE_MS = 500;
|
|
6
|
+
// clip.exe preserves a supplied BOM and guesses BOM-less UTF-16 incorrectly for
|
|
7
|
+
// short CJK text. Decode stdin explicitly; never interpolate clipboard content.
|
|
8
|
+
const WINDOWS_CLIPBOARD_COMMAND = "$ErrorActionPreference='Stop'; "
|
|
9
|
+
+ '$reader=[IO.StreamReader]::new([Console]::OpenStandardInput(),'
|
|
10
|
+
+ '[Text.UTF8Encoding]::new($false),$false); Set-Clipboard -Value $reader.ReadToEnd()';
|
|
11
|
+
/** Encode the full clipboard selection as UTF-8, without truncating its payload. */
|
|
40
12
|
export function osc52(text) {
|
|
41
|
-
return `\x1b]52;c;${Buffer.from(text, '
|
|
13
|
+
return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`;
|
|
42
14
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
:
|
|
48
|
-
|
|
49
|
-
|
|
15
|
+
function sendOsc52(text, options, fallbackReason) {
|
|
16
|
+
if (options.signal?.aborted)
|
|
17
|
+
return { status: 'failed', reason: 'cancelled' };
|
|
18
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_OSC52_BYTES) {
|
|
19
|
+
return { status: 'failed', reason: 'too-large' };
|
|
20
|
+
}
|
|
21
|
+
if (!options.write)
|
|
22
|
+
return { status: 'failed', reason: fallbackReason ?? 'unavailable' };
|
|
50
23
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
/* swallow — clipboard is best-effort, and a missing xclip is ordinary */
|
|
54
|
-
});
|
|
55
|
-
child.stdin?.end(text);
|
|
56
|
-
return true;
|
|
24
|
+
options.write(osc52(text));
|
|
25
|
+
return { status: 'sent', via: 'osc52', ...(fallbackReason ? { fallbackReason } : {}) };
|
|
57
26
|
}
|
|
58
27
|
catch {
|
|
59
|
-
return
|
|
28
|
+
return { status: 'failed', reason: 'write' };
|
|
60
29
|
}
|
|
61
30
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
31
|
+
function settledTask(result) {
|
|
32
|
+
return { result: Promise.resolve(result), released: Promise.resolve() };
|
|
33
|
+
}
|
|
34
|
+
function spawnNative() {
|
|
35
|
+
const windows = process.platform === 'win32';
|
|
36
|
+
const file = windows ? 'powershell.exe' : process.platform === 'darwin' ? 'pbcopy' : 'xclip';
|
|
37
|
+
const args = windows
|
|
38
|
+
? ['-NoProfile', '-NonInteractive', '-STA', '-Command', WINDOWS_CLIPBOARD_COMMAND]
|
|
39
|
+
: file === 'xclip' ? ['-selection', 'clipboard'] : [];
|
|
40
|
+
return spawn(file, args, {
|
|
41
|
+
stdio: ['pipe', 'ignore', 'ignore'], shell: false, windowsHide: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Owns a writer through close, even when its user-facing result timed out earlier. */
|
|
45
|
+
class NativeClipboardTask {
|
|
46
|
+
child;
|
|
47
|
+
text;
|
|
48
|
+
options;
|
|
49
|
+
result;
|
|
50
|
+
released;
|
|
51
|
+
resolveResult;
|
|
52
|
+
resolveReleased;
|
|
53
|
+
settled = false;
|
|
54
|
+
closed = false;
|
|
55
|
+
stopping = false;
|
|
56
|
+
failure;
|
|
57
|
+
timer;
|
|
58
|
+
cleanupTimer;
|
|
59
|
+
constructor(child, text, options) {
|
|
60
|
+
this.child = child;
|
|
61
|
+
this.text = text;
|
|
62
|
+
this.options = options;
|
|
63
|
+
this.result = new Promise((resolve) => { this.resolveResult = resolve; });
|
|
64
|
+
this.released = new Promise((resolve) => { this.resolveReleased = resolve; });
|
|
65
|
+
child.on('error', this.onError);
|
|
66
|
+
child.once('close', this.onClose);
|
|
67
|
+
child.stdin?.on('error', this.onStdinError);
|
|
68
|
+
options.signal?.addEventListener('abort', this.onAbort, { once: true });
|
|
69
|
+
this.timer = setTimeout(() => this.stop('timeout'), options.timeoutMs ?? 1500);
|
|
70
|
+
if (options.signal?.aborted) {
|
|
71
|
+
this.onAbort();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!child.stdin) {
|
|
75
|
+
this.stop('write');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
79
78
|
try {
|
|
80
|
-
|
|
81
|
-
sentOsc52 = true;
|
|
79
|
+
child.stdin.end(Buffer.from(text, 'utf8'));
|
|
82
80
|
}
|
|
83
81
|
catch {
|
|
84
|
-
|
|
82
|
+
this.stop('write');
|
|
85
83
|
}
|
|
86
84
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
settle(result) {
|
|
86
|
+
if (this.settled)
|
|
87
|
+
return;
|
|
88
|
+
this.settled = true;
|
|
89
|
+
clearTimeout(this.timer);
|
|
90
|
+
clearTimeout(this.cleanupTimer);
|
|
91
|
+
this.resolveResult(result);
|
|
92
|
+
}
|
|
93
|
+
stop(reason) {
|
|
94
|
+
if (this.closed || this.stopping)
|
|
95
|
+
return;
|
|
96
|
+
this.stopping = true;
|
|
97
|
+
this.failure = reason;
|
|
98
|
+
clearTimeout(this.timer);
|
|
99
|
+
// No fallback before close: even a successful kill request is not proof of exit.
|
|
100
|
+
if (!this.settled) {
|
|
101
|
+
this.cleanupTimer = setTimeout(() => {
|
|
102
|
+
this.settle({ status: 'failed', reason });
|
|
103
|
+
}, CLEANUP_GRACE_MS);
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
this.child.stdin?.destroy();
|
|
107
|
+
}
|
|
108
|
+
catch { /* Keep error listeners until close. */ }
|
|
109
|
+
try {
|
|
110
|
+
this.child.kill();
|
|
111
|
+
}
|
|
112
|
+
catch { /* A failed kill must not release the writer lock. */ }
|
|
113
|
+
}
|
|
114
|
+
onAbort = () => {
|
|
115
|
+
this.settle({ status: 'failed', reason: 'cancelled' });
|
|
116
|
+
this.stop('cancelled');
|
|
117
|
+
};
|
|
118
|
+
onError = () => { this.stop('unavailable'); };
|
|
119
|
+
onStdinError = () => { this.stop('write'); };
|
|
120
|
+
onClose = (code) => {
|
|
121
|
+
this.closed = true;
|
|
122
|
+
clearTimeout(this.timer);
|
|
123
|
+
clearTimeout(this.cleanupTimer);
|
|
124
|
+
this.options.signal?.removeEventListener('abort', this.onAbort);
|
|
125
|
+
if (!this.settled) {
|
|
126
|
+
const result = code === 0 && !this.failure
|
|
127
|
+
? { status: 'confirmed', via: 'native' }
|
|
128
|
+
: sendOsc52(this.text, this.options, this.failure ?? 'write');
|
|
129
|
+
this.settle(result);
|
|
130
|
+
}
|
|
131
|
+
this.child.removeListener('error', this.onError);
|
|
132
|
+
this.child.stdin?.removeListener('error', this.onStdinError);
|
|
133
|
+
this.resolveReleased();
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Start a copy without rejecting; result and writer release are separate signals. */
|
|
137
|
+
export function startClipboardTask(text, options = {}) {
|
|
138
|
+
if (options.signal?.aborted)
|
|
139
|
+
return settledTask({ status: 'failed', reason: 'cancelled' });
|
|
140
|
+
if (text.length === 0)
|
|
141
|
+
return settledTask({ status: 'failed', reason: 'empty' });
|
|
142
|
+
const remote = options.remote ?? Boolean(process.env.SSH_CONNECTION || process.env.SSH_TTY);
|
|
143
|
+
if (remote)
|
|
144
|
+
return settledTask(sendOsc52(text, options));
|
|
145
|
+
let child;
|
|
146
|
+
try {
|
|
147
|
+
child = spawnNative();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return settledTask(sendOsc52(text, options, 'unavailable'));
|
|
151
|
+
}
|
|
152
|
+
return new NativeClipboardTask(child, text, options);
|
|
153
|
+
}
|
|
154
|
+
/** Promise convenience wrapper; interactive callers must use the shared coordinator. */
|
|
155
|
+
export function copyText(text, options = {}) {
|
|
156
|
+
return startClipboardTask(text, options).result;
|
|
91
157
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type EditorState, type InputSegment } from './editor-reducer.js';
|
|
2
|
+
export interface ComposerSubmitResult {
|
|
3
|
+
accepted: boolean;
|
|
4
|
+
reason?: string;
|
|
5
|
+
}
|
|
6
|
+
export type ComposerInputIntent = InputSegment | {
|
|
7
|
+
kind: 'newline';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'submit';
|
|
10
|
+
};
|
|
11
|
+
export interface ComposerInputOptions {
|
|
12
|
+
editor: EditorState;
|
|
13
|
+
intents: readonly ComposerInputIntent[];
|
|
14
|
+
resolveSubmit: (editor: EditorState) => string | null;
|
|
15
|
+
checkPasteLimit: (segments: InputSegment[], editor: EditorState) => string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface ComposerInputPlan {
|
|
18
|
+
nextEditor: EditorState;
|
|
19
|
+
rejectedEditor: EditorState;
|
|
20
|
+
submission?: string;
|
|
21
|
+
notice?: string;
|
|
22
|
+
refusal?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Plan both accepted and rejected drafts before any submission side effect.
|
|
26
|
+
* Uses caller-assigned paste IDs and pure callbacks; never mutates the source editor.
|
|
27
|
+
*/
|
|
28
|
+
export declare function planComposerInput(options: ComposerInputOptions): ComposerInputPlan;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { editorReducer } from './editor-reducer.js';
|
|
2
|
+
const MORE_INPUT_NOTICE = 'More input is kept in the draft; press Enter to send.';
|
|
3
|
+
/**
|
|
4
|
+
* Plan both accepted and rejected drafts before any submission side effect.
|
|
5
|
+
* Uses caller-assigned paste IDs and pure callbacks; never mutates the source editor.
|
|
6
|
+
*/
|
|
7
|
+
export function planComposerInput(options) {
|
|
8
|
+
let nextEditor = options.editor;
|
|
9
|
+
let rejectedEditor = options.editor;
|
|
10
|
+
let acceptedBase = options.editor;
|
|
11
|
+
let acceptedPastes = [];
|
|
12
|
+
const rejectedPastes = [];
|
|
13
|
+
let submission;
|
|
14
|
+
let notice;
|
|
15
|
+
for (const intent of options.intents) {
|
|
16
|
+
if (intent.kind === 'submit') {
|
|
17
|
+
const text = options.resolveSubmit(nextEditor);
|
|
18
|
+
if (submission !== undefined) {
|
|
19
|
+
notice = MORE_INPUT_NOTICE;
|
|
20
|
+
}
|
|
21
|
+
else if (text !== null && text.trim().length > 0) {
|
|
22
|
+
submission = text;
|
|
23
|
+
nextEditor = editorReducer(nextEditor, { type: 'clear' });
|
|
24
|
+
acceptedBase = nextEditor;
|
|
25
|
+
acceptedPastes = [];
|
|
26
|
+
}
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const segment = intent.kind === 'newline'
|
|
30
|
+
? { kind: 'text', text: '\n' } : intent;
|
|
31
|
+
const segments = [segment];
|
|
32
|
+
if (segment.kind === 'paste') {
|
|
33
|
+
// Inline pastes have no map entry; count every paste in this callback as before.
|
|
34
|
+
acceptedPastes.push(segment);
|
|
35
|
+
rejectedPastes.push(segment);
|
|
36
|
+
const refusal = options.checkPasteLimit(acceptedPastes, acceptedBase)
|
|
37
|
+
?? options.checkPasteLimit(rejectedPastes, options.editor);
|
|
38
|
+
if (refusal) {
|
|
39
|
+
return { nextEditor: options.editor, rejectedEditor: options.editor, refusal };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
nextEditor = editorReducer(nextEditor, { type: 'input', segments });
|
|
43
|
+
rejectedEditor = editorReducer(rejectedEditor, { type: 'input', segments });
|
|
44
|
+
}
|
|
45
|
+
return { nextEditor, rejectedEditor, submission, notice };
|
|
46
|
+
}
|