mixdog 0.9.85 → 0.9.87

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.
Files changed (102) hide show
  1. package/README.md +7 -1
  2. package/package.json +4 -3
  3. package/src/defaults/agents.json +12 -0
  4. package/src/defaults/skills/setup/SKILL.md +44 -12
  5. package/src/help.mjs +40 -9
  6. package/src/lib/keychain-cjs.cjs +11 -1
  7. package/src/rules/agent/43-title-agent.md +22 -0
  8. package/src/rules/shared/01-tool.md +3 -3
  9. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +24 -0
  11. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +84 -3
  12. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  13. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +2 -2
  14. package/src/runtime/agent/orchestrator/session/context-utils.mjs +73 -45
  15. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +15 -18
  16. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +1 -1
  17. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +13 -0
  18. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +135 -9
  19. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +31 -12
  20. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +4 -0
  21. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -0
  22. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +170 -0
  23. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +45 -6
  24. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  25. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +26 -0
  26. package/src/runtime/agent/orchestrator/session/token-bpe.mjs +42 -0
  27. package/src/runtime/agent/orchestrator/session/token-native.mjs +186 -0
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +15 -5
  29. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +20 -0
  30. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +24 -0
  32. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  34. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +286 -0
  35. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  36. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  37. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +152 -18
  38. package/src/runtime/agent/orchestrator/tools/token-binary-fetcher.mjs +201 -0
  39. package/src/runtime/agent/orchestrator/tools/token-manifest.json +26 -0
  40. package/src/runtime/channels/lib/webhook/relay-tunnel.mjs +3 -3
  41. package/src/runtime/media/adapters/gemini-video.mjs +1 -1
  42. package/src/runtime/media/renditions.mjs +1 -1
  43. package/src/runtime/media/store.mjs +1 -1
  44. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  45. package/src/runtime/memory/tool-defs.mjs +19 -3
  46. package/src/runtime/shared/atomic-file.mjs +25 -14
  47. package/src/runtime/shared/automation-attachments.mjs +3 -3
  48. package/src/runtime/shared/child-guardian.mjs +15 -5
  49. package/src/runtime/shared/child-spawn-gate.mjs +6 -12
  50. package/src/runtime/shared/resource-admission.mjs +7 -3
  51. package/src/runtime/shared/turn-snapshot.mjs +0 -3
  52. package/src/session-runtime/lifecycle-api.mjs +49 -9
  53. package/src/session-runtime/prewarm.mjs +35 -0
  54. package/src/session-runtime/provider-auth-api.mjs +8 -0
  55. package/src/session-runtime/provider-usage.mjs +30 -5
  56. package/src/session-runtime/remote-control.mjs +8 -47
  57. package/src/session-runtime/remote-transcript.mjs +3 -7
  58. package/src/session-runtime/route-preparation.mjs +24 -0
  59. package/src/session-runtime/runtime-core.mjs +286 -548
  60. package/src/session-runtime/session-lifecycle.mjs +378 -0
  61. package/src/session-runtime/session-turn-api.mjs +9 -2
  62. package/src/session-runtime/workflow-agents-api.mjs +3 -0
  63. package/src/standalone/agent-shard/shard-child.mjs +300 -0
  64. package/src/standalone/agent-shard/shard-pool.mjs +443 -0
  65. package/src/standalone/agent-tool/job-views.mjs +329 -0
  66. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  67. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  68. package/src/standalone/agent-tool.mjs +112 -941
  69. package/src/standalone/channel-daemon-transport.mjs +126 -185
  70. package/src/standalone/channel-daemon.mjs +9 -10
  71. package/src/standalone/usage-dashboard.mjs +23 -1
  72. package/src/tui/App.jsx +362 -2569
  73. package/src/tui/app/app-view.jsx +504 -0
  74. package/src/tui/app/channel-pickers.mjs +3 -8
  75. package/src/tui/app/create-app-pickers.mjs +313 -0
  76. package/src/tui/app/prompt-submit.mjs +501 -0
  77. package/src/tui/app/route-pickers.mjs +117 -152
  78. package/src/tui/app/settings-picker.mjs +90 -89
  79. package/src/tui/app/shell-layout.mjs +563 -0
  80. package/src/tui/app/slash-commands.mjs +7 -7
  81. package/src/tui/app/slash-dispatch.mjs +0 -45
  82. package/src/tui/app/usage-context-panels.mjs +288 -0
  83. package/src/tui/app/use-copy-selection.mjs +56 -0
  84. package/src/tui/app/use-global-key-input.mjs +151 -0
  85. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  86. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  87. package/src/tui/app/use-prompt-hint.mjs +58 -0
  88. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  89. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  90. package/src/tui/app/use-transcript-activity.mjs +131 -0
  91. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  92. package/src/tui/components/StatusLine.jsx +1 -1
  93. package/src/tui/components/tool-execution/ResultBody.jsx +1 -1
  94. package/src/tui/dist/index.mjs +9857 -9159
  95. package/src/tui/engine/session-api-ext.mjs +47 -9
  96. package/src/tui/engine.mjs +7 -1
  97. package/src/tui/figures.mjs +0 -1
  98. package/src/ui/statusline-format.mjs +0 -1
  99. package/src/hooks/lib/permission-evaluator.cjs +0 -24
  100. package/src/lib/config-cjs.cjs +0 -61
  101. package/src/runtime/shared/launcher-control.mjs +0 -258
  102. package/src/runtime/shared/workspace-router.mjs +0 -259
@@ -35,10 +35,8 @@ export function createSlashDispatch({
35
35
  openThemePicker,
36
36
  themeNotice,
37
37
  openEffortPicker,
38
- projectNameFromPath,
39
38
  enterProject,
40
39
  openProjectPicker,
41
- openToolsPicker,
42
40
  openMcpPicker,
43
41
  openSkillsPicker,
44
42
  openPluginsPicker,
@@ -231,19 +229,6 @@ export function createSlashDispatch({
231
229
  .catch((e) => store.pushNotice(`Couldn’t update fast mode: ${e?.message || e}`, 'error'));
232
230
  return true;
233
231
  }
234
- case 'cwd': {
235
- const nextPath = arg.trim();
236
- if (!nextPath) {
237
- store.pushNotice(`Project path: ${state.cwd}`, 'info');
238
- return true;
239
- }
240
- try {
241
- store.setCwd?.(nextPath, { message: `Project set: ${projectNameFromPath(nextPath)}` });
242
- } catch (e) {
243
- store.pushNotice(`project switch failed: ${e?.message || e}`, 'error');
244
- }
245
- return true;
246
- }
247
232
  case 'project': {
248
233
  const target = arg.trim();
249
234
  if (target) {
@@ -253,9 +238,6 @@ export function createSlashDispatch({
253
238
  openProjectPicker();
254
239
  return true;
255
240
  }
256
- case 'tools':
257
- openToolsPicker(arg.trim());
258
- return true;
259
241
  case 'mcp':
260
242
  openMcpPicker();
261
243
  return true;
@@ -280,23 +262,6 @@ export function createSlashDispatch({
280
262
  // palette, and a typed command answers instead of opening a picker.
281
263
  store.pushNotice('Schedules and webhooks are managed in the Mixdog desktop app', 'info');
282
264
  return true;
283
- case 'auth':
284
- store.pushNotice('/auth moved to /providers', 'info');
285
- void openProviderSetupPicker();
286
- return true;
287
- case 'auth-forget': {
288
- const providerId = arg.trim();
289
- if (!providerId) {
290
- store.pushNotice('usage: /auth-forget <provider>', 'warn');
291
- return true;
292
- }
293
- try {
294
- store.forgetProviderAuth(providerId);
295
- } catch (e) {
296
- store.pushNotice(`auth-forget failed: ${e?.message || e}`, 'error');
297
- }
298
- return true;
299
- }
300
265
  case 'memory': {
301
266
  if (!arg.trim()) {
302
267
  openMemoryCorePicker({ returnTo: null });
@@ -306,16 +271,6 @@ export function createSlashDispatch({
306
271
  .catch((e) => store.pushNotice(`memory failed: ${e?.message || e}`, 'error'));
307
272
  return true;
308
273
  }
309
- case 'recall': {
310
- const query = arg.trim();
311
- if (!query) {
312
- store.pushNotice('usage: /recall <query>', 'warn');
313
- return true;
314
- }
315
- void store.recall?.(query)
316
- .catch((e) => store.pushNotice(`recall failed: ${e?.message || e}`, 'error'));
317
- return true;
318
- }
319
274
  case 'autoclear': {
320
275
  const value = arg.trim().toLowerCase();
321
276
  if (!value) {
@@ -0,0 +1,288 @@
1
+ // /usage and /context panel builders, extracted from App.jsx. Follows the
2
+ // createRoutePickers factory pattern: called each render with the current
3
+ // store/state and panel setters; opening either surface closes every other
4
+ // prompt/picker first so exactly one bottom surface owns the screen.
5
+ export function createUsageContextPanels({
6
+ store,
7
+ state,
8
+ usageRequestRef,
9
+ setPicker,
10
+ setProviderPrompt,
11
+ setChannelPrompt,
12
+ setHookPrompt,
13
+ setSettingsPrompt,
14
+ setContextPanel,
15
+ setUsagePanel,
16
+ closeUsagePanel,
17
+ }) {
18
+ const openUsagePanel = (arg = '') => {
19
+ const refresh = /(?:^|\s)(?:refresh|--refresh|-r|true)(?:\s|$)/i.test(String(arg || ''));
20
+ const requestId = usageRequestRef.current + 1;
21
+ usageRequestRef.current = requestId;
22
+ setProviderPrompt(null);
23
+ setChannelPrompt(null);
24
+ setHookPrompt(null);
25
+ setSettingsPrompt(null);
26
+ setPicker(null);
27
+ setContextPanel(null);
28
+ setUsagePanel({
29
+ title: 'Provider Quotas',
30
+ subtitle: 'Statusline-style provider quota windows.',
31
+ checking: true,
32
+ refresh,
33
+ rows: [],
34
+ total: null,
35
+ });
36
+ setTimeout(() => {
37
+ if (usageRequestRef.current !== requestId) return;
38
+ void store.getUsageDashboard?.({
39
+ refresh,
40
+ onUpdate: (dashboard) => {
41
+ if (usageRequestRef.current !== requestId) return;
42
+ if (!dashboard) return;
43
+ setUsagePanel(dashboard);
44
+ },
45
+ })
46
+ .then((dashboard) => {
47
+ if (usageRequestRef.current !== requestId) return;
48
+ if (!dashboard) {
49
+ closeUsagePanel();
50
+ store.pushNotice('usage dashboard unavailable', 'warn');
51
+ return;
52
+ }
53
+ setUsagePanel(dashboard);
54
+ })
55
+ .catch((e) => {
56
+ if (usageRequestRef.current !== requestId) return;
57
+ closeUsagePanel();
58
+ store.pushNotice(`usage failed: ${e?.message || e}`, 'error');
59
+ });
60
+ }, 0);
61
+ };
62
+
63
+ const openContextPicker = () => {
64
+ const tools = store.toolsStatus?.() || {
65
+ activeCount: 0,
66
+ count: 0,
67
+ mcpToolCount: 0,
68
+ activeMcpToolCount: 0,
69
+ activeTools: [],
70
+ };
71
+ const mcp = store.mcpStatus?.() || { connectedCount: 0, configuredCount: 0, failedCount: 0 };
72
+ const skills = store.skillsStatus?.() || { count: 0 };
73
+ const plugins = store.pluginsStatus?.() || { count: 0 };
74
+ const context = store.contextStatus?.() || {};
75
+ const usage = context.usage || {};
76
+ const messages = context.messages || {};
77
+ const request = context.request || {};
78
+ const schemaBreakdown = request.toolSchemaBreakdown || {};
79
+ const schemaTokensFor = (buckets) => buckets.reduce(
80
+ (sum, bucket) => sum + Number(schemaBreakdown?.[bucket]?.tokens || 0),
81
+ 0,
82
+ );
83
+ const builtInToolSchemaTokens = schemaTokensFor(['code', 'web', 'mutation', 'channels', 'setup', 'other']);
84
+ const mcpToolSchemaTokens = schemaTokensFor(['mcp']);
85
+ const compaction = context.compaction || {};
86
+ const windowTokens = Number(context.contextWindow || state.contextWindow || context.rawContextWindow || state.rawContextWindow || 0);
87
+ const rawWindowTokens = Number(context.rawContextWindow || state.rawContextWindow || windowTokens || 0);
88
+ // Compaction boundary/trigger are sourced from the runtime contextStatus
89
+ // (context.compaction). Fall back to the visible window for the boundary
90
+ // and to the boundary for the trigger so /context still renders on a
91
+ // fresh/resumed session before any compaction telemetry exists.
92
+ const compactBoundary = Number(compaction.boundaryTokens || windowTokens || 0);
93
+ const compactTrigger = Number(compaction.triggerTokens || compactBoundary || 0);
94
+ const usedTokens = Number(context.usedTokens || context.currentEstimatedTokens || usage.lastContextTokens || 0);
95
+ const freeTokens = windowTokens ? Math.max(0, windowTokens - usedTokens) : Number(context.freeTokens || 0);
96
+ const pct = (value, total = windowTokens) => {
97
+ const n = Number(value || 0);
98
+ const d = Number(total || 0);
99
+ if (!d) return 'N/A';
100
+ const p = Math.max(0, Math.min(100, (n / d) * 100));
101
+ return `${p > 0 && p < 1 ? p.toFixed(1) : Math.floor(p)}%`;
102
+ };
103
+ const fmt = (value) => {
104
+ const n = Number(value || 0);
105
+ if (!Number.isFinite(n) || n <= 0) return '0';
106
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`;
107
+ if (n >= 10_000) return `${Math.round(n / 1000)}k`;
108
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
109
+ return `${Math.round(n)}`;
110
+ };
111
+ const cachedRead = Number(usage.lastCachedReadTokens || 0);
112
+ const cacheWrite = Number(usage.lastCacheWriteTokens || 0);
113
+ const freshInput = Number(
114
+ usage.lastUncachedInputTokens != null
115
+ ? usage.lastUncachedInputTokens
116
+ : Math.max(Number(usage.lastInputTokens || 0) - cachedRead - cacheWrite, 0)
117
+ );
118
+ const cacheDenom = Number(usage.lastContextTokens || 0) || (cachedRead + freshInput + cacheWrite);
119
+ const cacheHitRate = cacheDenom > 0
120
+ ? `${((cachedRead / cacheDenom) * 100).toFixed(0)}%`
121
+ : 'N/A';
122
+ const cacheWriteLabel = cacheWrite > 0 ? ` · ${fmt(cacheWrite)} write` : '';
123
+ const contextSource = context.usedSource === 'last_api_request' ? 'last API request' : 'estimated';
124
+ const lastApiLabel = context.lastApiRequestStale ? 'last API request (pre-compact)' : 'last API request';
125
+ const compactElapsed = (value) => {
126
+ const n = Number(value || 0);
127
+ if (!Number.isFinite(n) || n <= 0) return '';
128
+ return `${Math.max(1, Math.ceil(n / 1000))}s`;
129
+ };
130
+ const compactRunning = compaction.inProgress === true || compaction.lastStage === 'compacting';
131
+ const autoClearFailed = compaction.lastStage === 'auto_clear_failed' || !!compaction.lastClearCompactError;
132
+ const autoClearStage = compaction.lastStage === 'auto_clear' || compaction.lastClearAt;
133
+ const compactDuration = compactElapsed(compaction.lastDurationMs);
134
+ const compactInterrupted = compaction.lastStage === 'interrupted';
135
+ const compactReactive = String(compaction.lastTrigger || '').toLowerCase() === 'reactive';
136
+ const compactState = compactRunning
137
+ ? 'Compacting conversation'
138
+ : compactInterrupted
139
+ ? 'Compact interrupted'
140
+ : autoClearFailed
141
+ ? `auto-clear skipped${compaction.lastClearCompactError ? `: ${compaction.lastClearCompactError}` : ''}`
142
+ : autoClearStage
143
+ ? 'Auto-clear complete'
144
+ : compaction.lastChanged
145
+ ? (compactReactive ? 'Compact complete (overflow recovery)' : 'Compact complete')
146
+ : 'Compact checked';
147
+ const compactDescription = compactDuration
148
+ ? `${compactState} · ${compactDuration}`
149
+ : compactState;
150
+ const contextRows = [
151
+ {
152
+ value: 'summary',
153
+ label: 'Context Usage',
154
+ description: `${fmt(usedTokens)}/${fmt(windowTokens)} (${pct(usedTokens)}) · ${fmt(freeTokens)} free · ${contextSource} · effective`,
155
+ _action: 'summary',
156
+ },
157
+ {
158
+ value: 'compaction',
159
+ label: 'Compaction',
160
+ description: compactDescription,
161
+ _action: 'compaction',
162
+ },
163
+ {
164
+ value: 'messages',
165
+ label: 'Messages',
166
+ description: `${fmt(messages.estimatedTokens)} tokens (${pct(messages.estimatedTokens)}) · ${messages.count || 0} messages`,
167
+ _action: 'messages',
168
+ },
169
+ {
170
+ value: 'tools',
171
+ label: 'Tools',
172
+ description: `${fmt(builtInToolSchemaTokens)} schema tokens (${pct(builtInToolSchemaTokens)}) · ${tools.activeCount || 0}/${tools.count || 0} active`,
173
+ _action: 'tools',
174
+ },
175
+ {
176
+ value: 'tool-io',
177
+ label: 'Tool calls/results',
178
+ description: `${messages.toolCallCount || 0} calls (${fmt(messages.toolCallTokens)}) · ${messages.toolResultCount || 0} results (${fmt(messages.toolResultTokens)})`,
179
+ _action: 'tool-io',
180
+ },
181
+ {
182
+ value: 'request',
183
+ label: 'Request overhead',
184
+ description: `${fmt(request.requestOverheadTokens)} framing · ${fmt(request.reserveTokens)} reserve incl. tools`,
185
+ _action: 'request',
186
+ },
187
+ {
188
+ value: 'last-api',
189
+ label: 'Last API usage',
190
+ description: `${fmt(usage.lastContextTokens)} context · ${fmt(freshInput)} uncached input · ${fmt(usage.lastOutputTokens)} output · ${lastApiLabel}`,
191
+ _action: 'last-api',
192
+ },
193
+ {
194
+ value: 'cache',
195
+ label: 'Prompt cache',
196
+ description: `${cacheHitRate} hit · ${fmt(usage.lastCachedReadTokens)} read${cacheWriteLabel} · ${fmt(freshInput)} new (last request)`,
197
+ _action: 'cache',
198
+ },
199
+ {
200
+ value: 'free',
201
+ label: 'Free space',
202
+ description: `${fmt(freeTokens)} tokens (${pct(freeTokens)}) · raw window ${fmt(rawWindowTokens)}`,
203
+ _action: 'free',
204
+ },
205
+ {
206
+ value: 'extensions',
207
+ label: 'Skills/plugins',
208
+ description: `${skills.count || 0} skills · ${plugins.count || 0} plugins`,
209
+ _action: 'extensions',
210
+ },
211
+ ];
212
+ setProviderPrompt(null);
213
+ setChannelPrompt(null);
214
+ setHookPrompt(null);
215
+ setSettingsPrompt(null);
216
+ setPicker(null);
217
+ setContextPanel({
218
+ kind: 'context',
219
+ title: 'Context Usage',
220
+ detail: {
221
+ type: 'context',
222
+ usage: {
223
+ usedTokens,
224
+ windowTokens,
225
+ freeTokens,
226
+ rawWindowTokens,
227
+ source: contextSource,
228
+ effective: true,
229
+ },
230
+ compaction: {
231
+ stage: compaction.lastStage || 'pending',
232
+ state: compactState,
233
+ triggerTokens: compactTrigger,
234
+ boundaryTokens: compactBoundary,
235
+ type: compaction.compactType || compaction.type || null,
236
+ bufferTokens: Number(compaction.bufferTokens || (compactBoundary && compactTrigger ? Math.max(0, compactBoundary - compactTrigger) : 0)) || null,
237
+ pressureTokens: Number(compaction.lastPressureTokens || compaction.currentEstimatedTokens || 0) || null,
238
+ lastChanged: compaction.lastChanged === true,
239
+ },
240
+ messages: {
241
+ tokens: messages.estimatedTokens,
242
+ count: messages.count,
243
+ semantic: messages.semantic,
244
+ },
245
+ tools: {
246
+ schemaTokens: builtInToolSchemaTokens,
247
+ active: tools.activeCount,
248
+ count: tools.count,
249
+ },
250
+ toolIo: {
251
+ calls: messages.toolCallCount,
252
+ results: messages.toolResultCount,
253
+ },
254
+ request: {
255
+ toolSchemaBreakdown: request.toolSchemaBreakdown,
256
+ overheadTokens: request.requestOverheadTokens,
257
+ reserveTokens: request.reserveTokens,
258
+ },
259
+ lastApi: {
260
+ contextTokens: usage.lastContextTokens,
261
+ inputTokens: freshInput,
262
+ rawInputTokens: usage.lastInputTokens,
263
+ outputTokens: usage.lastOutputTokens,
264
+ },
265
+ cache: {
266
+ hitRate: cacheHitRate,
267
+ readTokens: usage.lastCachedReadTokens,
268
+ writeTokens: cacheWrite,
269
+ },
270
+ extensions: {
271
+ skills: skills.count,
272
+ plugins: plugins.count,
273
+ },
274
+ mcp: {
275
+ connected: mcp.connectedCount,
276
+ configured: mcp.configuredCount,
277
+ failed: mcp.failedCount,
278
+ tools: tools.mcpToolCount,
279
+ activeTools: tools.activeMcpToolCount,
280
+ schemaTokens: mcpToolSchemaTokens,
281
+ },
282
+ },
283
+ rows: contextRows,
284
+ });
285
+ };
286
+
287
+ return { openUsagePanel, openContextPicker };
288
+ }
@@ -0,0 +1,56 @@
1
+ // Ctrl+C selection copy, extracted from App.jsx. Merges the three harvest
2
+ // sources — the current render's visible selection, the last fully-painted
3
+ // remembered text, and the cross-scroll stitch buffer (only when contiguous)
4
+ // — retries briefly while ink refreshes, and reports feedback via the prompt
5
+ // hint band.
6
+ import { useCallback } from 'react';
7
+ import { copyToClipboard } from './clipboard.mjs';
8
+
9
+ export function useCopySelection({
10
+ store,
11
+ selectionTextRef,
12
+ getStitchedSelectionText,
13
+ showSelectionCopyHint,
14
+ }) {
15
+ const copySelection = useCallback((attempt = 0) => {
16
+ const renderText = store.getRenderSelectionText?.();
17
+ const remembered = selectionTextRef.current || '';
18
+ // A selection that has partially scrolled out of the viewport renders —
19
+ // and therefore harvests — only its visible rows. The remembered text
20
+ // (captured while the selection was last fully painted) is the fuller
21
+ // copy; prefer whichever is longer so scrolling never shrinks a copy.
22
+ let text = renderText == null
23
+ ? remembered
24
+ : (remembered.length > renderText.length ? remembered : renderText);
25
+ // The stitch buffer accumulates rows harvested across every scroll position
26
+ // during a transcript drag, so it can reconstruct rows that scrolled out of
27
+ // view entirely (neither renderText nor the last-full-paint remembered text
28
+ // ever saw them). getStitchedSelectionText reports a `complete` flag:
29
+ // prefer the stitch ONLY when it contiguously covers the selection (no
30
+ // interior gap) AND adds rows. A gapped stitch silently drops a scrolled-off
31
+ // row, so preferring it purely on length yielded a mangled copy.
32
+ const stitched = getStitchedSelectionText?.() || { text: '', complete: false };
33
+ if (stitched.complete && stitched.text.length > text.length) text = stitched.text;
34
+ if ((!text || !text.trim()) && attempt < 4) {
35
+ setTimeout(() => copySelection(attempt + 1), attempt === 0 ? 0 : 24);
36
+ return;
37
+ }
38
+ if (!text || !text.trim()) {
39
+ // Retries exhausted with nothing to copy: never return silently — the
40
+ // user pressed Ctrl+C expecting feedback. Surface a hint (and still
41
+ // swallow the key, which the caller already did).
42
+ showSelectionCopyHint('nothing to copy · select text first', 'error');
43
+ return;
44
+ }
45
+ selectionTextRef.current = text;
46
+ copyToClipboard(text)
47
+ .then(() => {
48
+ const lines = text.split('\n').length;
49
+ const chars = text.length;
50
+ showSelectionCopyHint(`copied ${chars} char${chars === 1 ? '' : 's'}${lines > 1 ? ` · ${lines} lines` : ''}`, 'plain');
51
+ })
52
+ .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
53
+ }, [store, selectionTextRef, showSelectionCopyHint, getStitchedSelectionText]);
54
+
55
+ return { copySelection };
56
+ }
@@ -0,0 +1,151 @@
1
+ // Global key input, extracted from App.jsx. Everything the app handles
2
+ // before PromptInput sees a key: tool-approval y/n, copy-first Ctrl+C with
3
+ // region-aware selection sources, Ctrl+O expand, shift-arrow grid-selection
4
+ // moves, panel Escapes, and PageUp/PageDown transcript paging.
5
+ import { useInput } from 'ink';
6
+ import { copyToClipboard } from './clipboard.mjs';
7
+ import { overlayBlocksGlobalTranscriptScroll } from './slash-commands.mjs';
8
+ export function useGlobalKeyInput({
9
+ store,
10
+ state,
11
+ toolApproval,
12
+ picker,
13
+ usagePanel,
14
+ contextPanel,
15
+ setContextPanel,
16
+ closeUsagePanel,
17
+ isRawModeSupported,
18
+ resizeState,
19
+ promptSelectionRef,
20
+ promptMouseSelectionRef,
21
+ dragRef,
22
+ scrollFocusRef,
23
+ gridSelectionActiveRef,
24
+ moveSelectionFocus,
25
+ copySelection,
26
+ showSelectionCopyHint,
27
+ toggleExpand,
28
+ scrollTranscriptRows,
29
+ resetTranscriptScroll,
30
+ applySelectionRect,
31
+ }) {
32
+ // (body moved verbatim)
33
+ useInput((input, key) => {
34
+ if (toolApproval) {
35
+ const value = String(input || '').trim().toLowerCase();
36
+ if (key.escape || value === 'd' || value === 'n') {
37
+ store.resolveToolApproval?.(toolApproval.id, { approved: false, reason: 'denied by user' });
38
+ return;
39
+ }
40
+ if (value === 'a' || value === 'y') {
41
+ store.resolveToolApproval?.(toolApproval.id, { approved: true, reason: 'approved by user' });
42
+ return;
43
+ }
44
+ }
45
+ if (key.ctrl && (input === 'c' || input === 'C')) {
46
+ // Ctrl+C is copy-first. Native terminal selections can still forward the
47
+ // key event to us on Windows Terminal, so a missing app-owned selection
48
+ // must NOT cancel the active turn; use Esc to interrupt instead.
49
+ // Region-aware copy source: a prompt-box selection (its OWN engine) copies
50
+ // from promptSelectionRef; a transcript/status ink-grid selection copies
51
+ // from store.getRenderSelectionText via copySelection(). Only one region is
52
+ // ever active at a time (a press in one region clears the others), but when
53
+ // the last drag was in the prompt we prefer its selection explicitly.
54
+ const promptSelectionText = promptSelectionRef.current?.text;
55
+ const lastRegion = dragRef.current.region;
56
+ const inkRect = dragRef.current.rect;
57
+ const hasInkSelection = inkRect && !(inkRect.x1 === inkRect.x2 && inkRect.y1 === inkRect.y2);
58
+ if (promptSelectionText && (lastRegion === 'prompt' || !hasInkSelection)) {
59
+ copyToClipboard(promptSelectionText)
60
+ .then(() => showSelectionCopyHint(`copied ${promptSelectionText.length} char${promptSelectionText.length === 1 ? '' : 's'}`, 'plain'))
61
+ .catch((e) => showSelectionCopyHint(`copy failed: ${e?.message || e}`, 'error'));
62
+ return;
63
+ }
64
+ if (hasInkSelection) {
65
+ copySelection();
66
+ return;
67
+ }
68
+ // No app-owned selection. On Windows Terminal the same Ctrl+C is also the
69
+ // native terminal's copy shortcut for a mouse selection we can't see — so
70
+ // rendering a hint here fights that copy and flashes a spurious message.
71
+ // Suppress the hint on win32 (interrupt routing is unchanged: Esc still
72
+ // interrupts). Other platforms keep the guidance.
73
+ if (process.platform !== 'win32') {
74
+ showSelectionCopyHint('select text to copy · Esc interrupts', 'plain');
75
+ }
76
+ return;
77
+ }
78
+ if (key.ctrl && (input === 'o' || input === 'O')) {
79
+ toggleExpand();
80
+ return;
81
+ }
82
+ const rawShiftUp = input === '\x1b[1;2A' || input === '\x1b[a' || input === '[1;2A';
83
+ const rawShiftDown = input === '\x1b[1;2B' || input === '\x1b[b' || input === '[1;2B';
84
+ const rawShiftRight = input === '\x1b[1;2C' || input === '\x1b[c' || input === '[1;2C';
85
+ const rawShiftLeft = input === '\x1b[1;2D' || input === '\x1b[d' || input === '[1;2D';
86
+ const rawCtrlShiftUp = input === '\x1b[1;6A' || input === '[1;6A';
87
+ const rawCtrlShiftDown = input === '\x1b[1;6B' || input === '[1;6B';
88
+ const rawCtrlShiftRight = input === '\x1b[1;6C' || input === '[1;6C';
89
+ const rawCtrlShiftLeft = input === '\x1b[1;6D' || input === '[1;6D';
90
+ const rawModifiedShiftArrow = rawShiftUp || rawShiftDown || rawShiftLeft || rawShiftRight
91
+ || rawCtrlShiftUp || rawCtrlShiftDown || rawCtrlShiftLeft || rawCtrlShiftRight;
92
+ if (
93
+ !picker
94
+ && (
95
+ (key.shift && (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.home || key.end))
96
+ || rawModifiedShiftArrow
97
+ )
98
+ ) {
99
+ // Consume the chord whenever a transcript/status ink-grid selection is
100
+ // live — even if the focus clamps at an edge (moveSelectionFocus returns
101
+ // false there). PromptInput independently skips the same chord via the
102
+ // shared gridSelectionActiveRef predicate, so there is no double-handling.
103
+ // When no grid selection is live, fall through to PromptInput.
104
+ let move = null;
105
+ if (key.leftArrow || rawShiftLeft || rawCtrlShiftLeft) move = 'left';
106
+ else if (key.rightArrow || rawShiftRight || rawCtrlShiftRight) move = 'right';
107
+ else if (key.upArrow || rawShiftUp || rawCtrlShiftUp) move = 'up';
108
+ else if (key.downArrow || rawShiftDown || rawCtrlShiftDown) move = 'down';
109
+ else if (key.home) move = 'lineStart';
110
+ else if (key.end) move = 'lineEnd';
111
+ if (move && gridSelectionActiveRef.current()) {
112
+ moveSelectionFocus(move);
113
+ return;
114
+ }
115
+ }
116
+ if (key.escape && usagePanel && !picker) {
117
+ closeUsagePanel();
118
+ return;
119
+ }
120
+ if (key.escape && contextPanel && !picker) {
121
+ setContextPanel(null);
122
+ return;
123
+ }
124
+ if (key.pageUp) {
125
+ if (overlayBlocksGlobalTranscriptScroll(scrollFocusRef.current)) return;
126
+ const pageRows = Math.max(3, Math.floor((resizeState.rows ?? 24) * 0.6));
127
+ scrollTranscriptRows(pageRows);
128
+ return;
129
+ }
130
+ if (key.pageDown) {
131
+ if (overlayBlocksGlobalTranscriptScroll(scrollFocusRef.current)) return;
132
+ const pageRows = Math.max(3, Math.floor((resizeState.rows ?? 24) * 0.6));
133
+ scrollTranscriptRows(-pageRows);
134
+ return;
135
+ }
136
+ if (key.ctrl && key.end) {
137
+ resetTranscriptScroll();
138
+ return;
139
+ }
140
+ if (key.escape && !picker) {
141
+ dragRef.current.active = false;
142
+ dragRef.current.region = null;
143
+ dragRef.current.anchorSpan = null;
144
+ // Clear whichever region's selection is active. PromptInput's own ESC also
145
+ // clears its selection when focused/enabled; this covers the disabled case
146
+ // and a status/transcript ink-grid selection in one press.
147
+ promptMouseSelectionRef.current?.clear?.();
148
+ applySelectionRect(null);
149
+ }
150
+ }, { isActive: isRawModeSupported });
151
+ }
@@ -0,0 +1,115 @@
1
+ // Pasted-attachment buffers, extracted from App.jsx. Large pasted images and
2
+ // texts fold into [Image #N] / [Pasted text #N +M lines] prompt tokens; the
3
+ // original payloads live here (ref + mirrored state) until submit expands
4
+ // them back. install* rehydrates a queued/restored draft's buffers, the
5
+ // snapshot clears drop exactly the entries a submit consumed, and register*
6
+ // mints the next token for a fresh paste.
7
+ import { useCallback, useRef, useState } from 'react';
8
+ import { formatImageRef, formatPastedTextRef } from '../paste-attachments.mjs';
9
+
10
+ export function usePastedBuffers() {
11
+ const [, setPastedImages] = useState({});
12
+ const pastedImagesRef = useRef({});
13
+ const nextPastedImageIdRef = useRef(1);
14
+ const [, setPastedTexts] = useState({});
15
+ const pastedTextsRef = useRef({});
16
+ const nextPastedTextIdRef = useRef(1);
17
+
18
+ const installPastedImages = useCallback((images, { merge = true } = {}) => {
19
+ if (!images || typeof images !== 'object' || Object.keys(images).length === 0) return;
20
+ const next = merge ? { ...pastedImagesRef.current, ...images } : { ...images };
21
+ pastedImagesRef.current = next;
22
+ const maxId = Object.keys(next)
23
+ .map((id) => Number(id) || 0)
24
+ .reduce((max, id) => Math.max(max, id), 0);
25
+ if (maxId >= nextPastedImageIdRef.current) nextPastedImageIdRef.current = maxId + 1;
26
+ setPastedImages(next);
27
+ }, []);
28
+
29
+ const clearPastedImagesSnapshot = useCallback((snapshot = null) => {
30
+ if (!snapshot) {
31
+ if (Object.keys(pastedImagesRef.current || {}).length === 0) return;
32
+ pastedImagesRef.current = {};
33
+ setPastedImages({});
34
+ return;
35
+ }
36
+ if (typeof snapshot !== 'object' || Object.keys(snapshot).length === 0) return;
37
+ const next = { ...pastedImagesRef.current };
38
+ let changed = false;
39
+ for (const [id, image] of Object.entries(snapshot)) {
40
+ if (next[id] === image) {
41
+ delete next[id];
42
+ changed = true;
43
+ }
44
+ }
45
+ if (!changed) return;
46
+ pastedImagesRef.current = next;
47
+ setPastedImages(next);
48
+ }, []);
49
+
50
+ const registerPastedImage = useCallback((image) => {
51
+ if (!image || image.type !== 'image' || !image.content) return '';
52
+ const id = nextPastedImageIdRef.current++;
53
+ const entry = { ...image, id };
54
+ pastedImagesRef.current = { ...pastedImagesRef.current, [id]: entry };
55
+ setPastedImages(pastedImagesRef.current);
56
+ return formatImageRef(id);
57
+ }, []);
58
+
59
+ const installPastedTexts = useCallback((texts, { merge = true } = {}) => {
60
+ if (!texts || typeof texts !== 'object' || Object.keys(texts).length === 0) return;
61
+ const next = merge ? { ...pastedTextsRef.current, ...texts } : { ...texts };
62
+ pastedTextsRef.current = next;
63
+ const maxId = Object.keys(next)
64
+ .map((id) => Number(id) || 0)
65
+ .reduce((max, id) => Math.max(max, id), 0);
66
+ if (maxId >= nextPastedTextIdRef.current) nextPastedTextIdRef.current = maxId + 1;
67
+ setPastedTexts(next);
68
+ }, []);
69
+
70
+ const clearPastedTextsSnapshot = useCallback((snapshot = null) => {
71
+ if (!snapshot) {
72
+ if (Object.keys(pastedTextsRef.current || {}).length === 0) return;
73
+ pastedTextsRef.current = {};
74
+ setPastedTexts({});
75
+ return;
76
+ }
77
+ if (typeof snapshot !== 'object' || Object.keys(snapshot).length === 0) return;
78
+ const next = { ...pastedTextsRef.current };
79
+ let changed = false;
80
+ for (const [id, text] of Object.entries(snapshot)) {
81
+ if (next[id] === text) {
82
+ delete next[id];
83
+ changed = true;
84
+ }
85
+ }
86
+ if (!changed) return;
87
+ pastedTextsRef.current = next;
88
+ setPastedTexts(next);
89
+ }, []);
90
+
91
+ const registerPastedText = useCallback((text) => {
92
+ const value = String(text ?? '');
93
+ if (!value) return '';
94
+ const id = nextPastedTextIdRef.current++;
95
+ const entry = { id, text: value };
96
+ pastedTextsRef.current = { ...pastedTextsRef.current, [id]: entry };
97
+ setPastedTexts(pastedTextsRef.current);
98
+ return formatPastedTextRef(id, value);
99
+ }, []);
100
+
101
+ return {
102
+ pastedImagesRef,
103
+ nextPastedImageIdRef,
104
+ pastedTextsRef,
105
+ nextPastedTextIdRef,
106
+ setPastedImages,
107
+ setPastedTexts,
108
+ installPastedImages,
109
+ clearPastedImagesSnapshot,
110
+ registerPastedImage,
111
+ installPastedTexts,
112
+ clearPastedTextsSnapshot,
113
+ registerPastedText,
114
+ };
115
+ }