@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +304 -6
- package/dist/cli.js +1015 -881
- package/dist/types/async/job-manager.d.ts +15 -0
- package/dist/types/autolearn/controller.d.ts +25 -0
- package/dist/types/autolearn/managed-skills.d.ts +45 -0
- package/dist/types/autoresearch/state.d.ts +1 -1
- package/dist/types/autoresearch/types.d.ts +1 -1
- package/dist/types/cli/args.d.ts +19 -1
- package/dist/types/cli/session-picker.d.ts +1 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/cli/setup-model-picker.d.ts +14 -0
- package/dist/types/collab/protocol.d.ts +1 -1
- package/dist/types/commands/say.d.ts +24 -0
- package/dist/types/config/keybindings.d.ts +3 -3
- package/dist/types/config/model-registry.d.ts +10 -0
- package/dist/types/config/models-config-schema.d.ts +12 -0
- package/dist/types/config/models-config.d.ts +8 -2
- package/dist/types/config/settings-schema.d.ts +261 -58
- package/dist/types/export/html/index.d.ts +2 -1
- package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +47 -1
- package/dist/types/extensibility/hooks/index.d.ts +2 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
- package/dist/types/extensibility/plugins/loader.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/extensibility/skills.d.ts +10 -0
- package/dist/types/goals/guided-setup.d.ts +18 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/hindsight/transcript.d.ts +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/internal-urls/local-protocol.d.ts +4 -2
- package/dist/types/main.d.ts +4 -3
- package/dist/types/mcp/startup-events.d.ts +11 -0
- package/dist/types/memories/index.d.ts +7 -0
- package/dist/types/memory-backend/local-backend.d.ts +4 -3
- package/dist/types/mnemopi/config.d.ts +4 -4
- package/dist/types/modes/components/agent-hub.d.ts +6 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
- package/dist/types/modes/components/custom-editor.d.ts +39 -1
- package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +26 -16
- package/dist/types/modes/components/transcript-container.d.ts +23 -2
- package/dist/types/modes/components/tree-selector.d.ts +1 -1
- package/dist/types/modes/components/usage-row.d.ts +3 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/gradient-highlight.d.ts +9 -4
- package/dist/types/modes/image-references.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +27 -3
- package/dist/types/modes/magic-keywords.d.ts +13 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
- package/dist/types/modes/runtime-init.d.ts +4 -0
- package/dist/types/modes/theme/theme.d.ts +13 -2
- package/dist/types/modes/types.d.ts +8 -2
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/registry/agent-registry.d.ts +17 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +14 -2
- package/dist/types/session/indexed-session-storage.d.ts +3 -4
- package/dist/types/session/session-context.d.ts +39 -0
- package/dist/types/session/session-entries.d.ts +159 -0
- package/dist/types/session/session-listing.d.ts +69 -0
- package/dist/types/session/session-loader.d.ts +16 -0
- package/dist/types/session/session-manager.d.ts +82 -474
- package/dist/types/session/session-migrations.d.ts +12 -0
- package/dist/types/session/session-paths.d.ts +25 -0
- package/dist/types/session/session-persistence.d.ts +8 -0
- package/dist/types/session/session-storage.d.ts +11 -12
- package/dist/types/session/snapcompact-inline.d.ts +12 -1
- package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
- package/dist/types/session/tool-choice-queue.d.ts +6 -6
- package/dist/types/stt/asr-client.d.ts +90 -0
- package/dist/types/stt/asr-protocol.d.ts +97 -0
- package/dist/types/stt/asr-worker.d.ts +2 -0
- package/dist/types/stt/downloader.d.ts +38 -0
- package/dist/types/stt/endpointer.d.ts +59 -0
- package/dist/types/stt/index.d.ts +5 -1
- package/dist/types/stt/models.d.ts +120 -0
- package/dist/types/stt/recorder.d.ts +17 -0
- package/dist/types/stt/stt-controller.d.ts +6 -0
- package/dist/types/stt/transcriber.d.ts +5 -7
- package/dist/types/stt/wav.d.ts +29 -0
- package/dist/types/system-prompt.d.ts +4 -0
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/task/index.d.ts +9 -1
- package/dist/types/task/types.d.ts +36 -0
- package/dist/types/tools/bash.d.ts +2 -2
- package/dist/types/tools/eval-render.d.ts +1 -1
- package/dist/types/tools/index.d.ts +11 -1
- package/dist/types/tools/irc.d.ts +1 -0
- package/dist/types/tools/learn.d.ts +51 -0
- package/dist/types/tools/manage-skill.d.ts +40 -0
- package/dist/types/tools/plan-mode-guard.d.ts +10 -0
- package/dist/types/tools/renderers.d.ts +7 -11
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +1 -1
- package/dist/types/tools/tts.d.ts +25 -0
- package/dist/types/tools/write.d.ts +1 -1
- package/dist/types/tts/downloader.d.ts +20 -0
- package/dist/types/tts/index.d.ts +8 -0
- package/dist/types/tts/models.d.ts +82 -0
- package/dist/types/tts/player.d.ts +32 -0
- package/dist/types/tts/runtime.d.ts +6 -0
- package/dist/types/tts/streaming-player.d.ts +41 -0
- package/dist/types/tts/tts-client.d.ts +93 -0
- package/dist/types/tts/tts-protocol.d.ts +95 -0
- package/dist/types/tts/tts-worker.d.ts +2 -0
- package/dist/types/tts/vocalizer.d.ts +41 -0
- package/dist/types/tts/wav.d.ts +8 -0
- package/dist/types/utils/tool-choice.d.ts +8 -0
- package/dist/types/utils/tools-manager.d.ts +2 -1
- package/dist/types/utils/tools-manager.test.d.ts +1 -0
- package/dist/types/web/scrapers/github.d.ts +1 -1
- package/package.json +15 -14
- package/src/async/job-manager.ts +49 -0
- package/src/autolearn/controller.ts +139 -0
- package/src/autolearn/managed-skills.ts +257 -0
- package/src/autoresearch/state.ts +1 -1
- package/src/autoresearch/types.ts +1 -1
- package/src/cli/args.ts +56 -2
- package/src/cli/session-picker.ts +2 -1
- package/src/cli/setup-cli.ts +148 -47
- package/src/cli/setup-model-picker.ts +43 -0
- package/src/cli-commands.ts +1 -0
- package/src/cli.ts +45 -13
- package/src/collab/host.ts +1 -1
- package/src/collab/protocol.ts +1 -1
- package/src/commands/say.ts +102 -0
- package/src/commands/setup.ts +1 -1
- package/src/commit/agentic/tools/analyze-file.ts +3 -0
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-discovery.ts +11 -5
- package/src/config/model-registry.ts +64 -9
- package/src/config/models-config-schema.ts +4 -1
- package/src/config/models-config.ts +2 -1
- package/src/config/settings-schema.ts +248 -32
- package/src/config/settings.ts +10 -0
- package/src/discovery/builtin.ts +23 -1
- package/src/discovery/claude-plugins.ts +44 -5
- package/src/discovery/helpers.ts +41 -1
- package/src/eval/__tests__/budget-bridge.test.ts +1 -1
- package/src/eval/js/shared/prelude.txt +69 -17
- package/src/export/html/index.ts +3 -6
- package/src/extensibility/extensions/model-api.ts +41 -0
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +52 -1
- package/src/extensibility/extensions/wrapper.ts +41 -5
- package/src/extensibility/hooks/index.ts +2 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
- package/src/extensibility/plugins/loader.ts +30 -19
- package/src/extensibility/plugins/manager.ts +221 -90
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +96 -15
- package/src/goals/guided-setup.ts +133 -0
- package/src/goals/state.ts +1 -1
- package/src/hindsight/transcript.ts +1 -1
- package/src/index.ts +5 -0
- package/src/internal-urls/docs-index.generated.ts +10 -10
- package/src/internal-urls/history-protocol.ts +1 -1
- package/src/internal-urls/local-protocol.ts +29 -7
- package/src/main.ts +27 -7
- package/src/mcp/startup-events.ts +21 -0
- package/src/mcp/transports/stdio.ts +2 -1
- package/src/memories/index.ts +146 -11
- package/src/memory-backend/local-backend.ts +11 -5
- package/src/mnemopi/backend.ts +1 -0
- package/src/mnemopi/config.ts +26 -10
- package/src/modes/acp/acp-agent.ts +3 -5
- package/src/modes/components/agent-hub.ts +49 -4
- package/src/modes/components/assistant-message.ts +4 -37
- package/src/modes/components/compaction-summary-message.ts +125 -26
- package/src/modes/components/custom-editor.test.ts +96 -0
- package/src/modes/components/custom-editor.ts +164 -8
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/settings-defs.ts +7 -0
- package/src/modes/components/tool-execution.ts +82 -43
- package/src/modes/components/transcript-container.ts +70 -1
- package/src/modes/components/tree-selector.ts +1 -1
- package/src/modes/components/usage-row.ts +18 -0
- package/src/modes/components/user-message.ts +4 -2
- package/src/modes/controllers/command-controller.ts +14 -4
- package/src/modes/controllers/event-controller.ts +78 -11
- package/src/modes/controllers/extension-ui-controller.ts +6 -0
- package/src/modes/controllers/input-controller.ts +258 -27
- package/src/modes/controllers/selector-controller.ts +12 -2
- package/src/modes/gradient-highlight.ts +21 -9
- package/src/modes/image-references.ts +20 -0
- package/src/modes/interactive-mode.ts +286 -40
- package/src/modes/magic-keywords.ts +27 -5
- package/src/modes/rpc/rpc-mode.ts +146 -14
- package/src/modes/rpc/rpc-subagents.ts +2 -2
- package/src/modes/rpc/rpc-types.ts +8 -2
- package/src/modes/runtime-init.ts +28 -3
- package/src/modes/theme/theme.ts +98 -50
- package/src/modes/types.ts +6 -2
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +34 -6
- package/src/priority.json +5 -1
- package/src/prompts/agents/task.md +1 -0
- package/src/prompts/goals/guided-goal-interview.md +8 -0
- package/src/prompts/goals/guided-goal-system.md +12 -0
- package/src/prompts/memories/read-path.md +6 -0
- package/src/prompts/system/autolearn-guidance-learn.md +1 -0
- package/src/prompts/system/autolearn-guidance.md +7 -0
- package/src/prompts/system/autolearn-nudge.md +3 -0
- package/src/prompts/system/eager-task.md +7 -0
- package/src/prompts/system/eager-todo.md +11 -6
- package/src/prompts/system/subagent-system-prompt.md +4 -0
- package/src/prompts/system/system-prompt.md +10 -5
- package/src/prompts/system/title-marker-instruction.md +1 -0
- package/src/prompts/system/title-system-marker.md +16 -0
- package/src/prompts/tools/job.md +1 -0
- package/src/prompts/tools/learn.md +7 -0
- package/src/prompts/tools/manage-skill.md +9 -0
- package/src/prompts/tools/task.md +3 -0
- package/src/registry/agent-registry.ts +30 -0
- package/src/sdk.ts +88 -24
- package/src/secrets/obfuscator.ts +1 -1
- package/src/session/agent-session.ts +209 -87
- package/src/session/history-storage.ts +2 -2
- package/src/session/indexed-session-storage.ts +7 -17
- package/src/session/session-context.ts +352 -0
- package/src/session/session-entries.ts +194 -0
- package/src/session/session-listing.ts +588 -0
- package/src/session/session-loader.ts +106 -0
- package/src/session/session-manager.ts +933 -3145
- package/src/session/session-migrations.ts +78 -0
- package/src/session/session-paths.ts +193 -0
- package/src/session/session-persistence.ts +131 -0
- package/src/session/session-storage.ts +91 -50
- package/src/session/snapcompact-inline.ts +21 -1
- package/src/session/snapcompact-savings-journal.ts +113 -0
- package/src/session/tool-choice-queue.ts +23 -11
- package/src/slash-commands/builtin-registry.ts +25 -3
- package/src/stt/asr-client.ts +520 -0
- package/src/stt/asr-protocol.ts +65 -0
- package/src/stt/asr-worker.ts +790 -0
- package/src/stt/downloader.ts +107 -47
- package/src/stt/endpointer.ts +259 -0
- package/src/stt/index.ts +5 -1
- package/src/stt/models.ts +150 -0
- package/src/stt/recorder.ts +247 -60
- package/src/stt/stt-controller.ts +201 -22
- package/src/stt/transcriber.ts +37 -68
- package/src/stt/wav.ts +173 -0
- package/src/system-prompt.ts +8 -0
- package/src/task/agents.ts +1 -2
- package/src/task/executor.ts +49 -15
- package/src/task/index.ts +60 -6
- package/src/task/render.ts +83 -8
- package/src/task/types.ts +53 -0
- package/src/tools/ask.ts +8 -0
- package/src/tools/bash.ts +4 -3
- package/src/tools/eval-render.ts +4 -3
- package/src/tools/index.ts +40 -4
- package/src/tools/irc.ts +10 -2
- package/src/tools/job.ts +14 -2
- package/src/tools/learn.ts +144 -0
- package/src/tools/manage-skill.ts +104 -0
- package/src/tools/plan-mode-guard.ts +53 -19
- package/src/tools/renderers.ts +7 -11
- package/src/tools/ssh.ts +4 -3
- package/src/tools/todo.ts +1 -1
- package/src/tools/tts.ts +203 -92
- package/src/tools/write.ts +18 -2
- package/src/tts/downloader.ts +64 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/models.ts +137 -0
- package/src/tts/player.ts +137 -0
- package/src/tts/runtime.ts +21 -0
- package/src/tts/streaming-player.ts +266 -0
- package/src/tts/tts-client.ts +647 -0
- package/src/tts/tts-protocol.ts +60 -0
- package/src/tts/tts-worker.ts +497 -0
- package/src/tts/vocalizer.ts +162 -0
- package/src/tts/wav.ts +58 -0
- package/src/utils/title-generator.ts +48 -5
- package/src/utils/tool-choice.ts +16 -0
- package/src/utils/tools-manager.test.ts +25 -0
- package/src/utils/tools-manager.ts +19 -1
- package/src/web/scrapers/github.ts +96 -0
- package/src/web/search/index.ts +13 -0
- package/src/web/search/providers/searxng.ts +13 -1
- package/dist/types/stt/setup.d.ts +0 -18
- package/src/stt/setup.ts +0 -52
- package/src/stt/transcribe.py +0 -70
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
3
4
|
import { type AutocompleteProvider, matchesKey, type SlashCommand } from "@oh-my-pi/pi-tui";
|
|
4
5
|
import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
5
6
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
7
|
+
import { resolveLocalRoot } from "../../internal-urls";
|
|
6
8
|
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
|
7
9
|
import { renderSegmentTrack } from "../../modes/components/segment-track";
|
|
8
10
|
import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
|
|
9
11
|
import { expandEmoticons } from "../../modes/emoji-autocomplete";
|
|
10
|
-
import { materializeImageReferenceLinks } from "../../modes/image-references";
|
|
12
|
+
import { materializeImageReferenceLinks, shiftImageMarkers } from "../../modes/image-references";
|
|
11
13
|
import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
|
|
12
14
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
13
15
|
import manualContinuePrompt from "../../prompts/system/manual-continue.md" with { type: "text" };
|
|
@@ -42,12 +44,44 @@ function hasPasteText(value: unknown): value is PasteTarget {
|
|
|
42
44
|
return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/** Wrap pasted text in a fenced code block, using a backtick fence longer than any run of
|
|
48
|
+
* backticks already in the content so an embedded fence cannot terminate the block early. */
|
|
49
|
+
function wrapPasteInCodeBlock(content: string): string {
|
|
50
|
+
let longestRun = 0;
|
|
51
|
+
let run = 0;
|
|
52
|
+
for (let i = 0; i < content.length; i++) {
|
|
53
|
+
if (content.charCodeAt(i) === 96 /* backtick */) {
|
|
54
|
+
run++;
|
|
55
|
+
if (run > longestRun) longestRun = run;
|
|
56
|
+
} else {
|
|
57
|
+
run = 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const fence = "`".repeat(Math.max(3, longestRun + 1));
|
|
61
|
+
return `${fence}\n${content}\n${fence}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Wrap pasted text in `<pasted_text>` tags so the model treats it as one quoted block. */
|
|
65
|
+
function wrapPasteInXml(content: string): string {
|
|
66
|
+
return `<pasted_text>\n${content}\n</pasted_text>`;
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
|
|
46
70
|
// A cached model fires its file-load events in a short burst and then goes silent
|
|
47
71
|
// while onnxruntime builds the session; a genuine download keeps streaming progress
|
|
48
72
|
// events for seconds. Only reveal the bar once a still-incomplete event arrives after
|
|
49
73
|
// this grace window, so an already-downloaded model never flashes the bar.
|
|
50
74
|
const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000;
|
|
75
|
+
// Double-tap ← on an empty editor opens the Agent Hub (and, in a focused
|
|
76
|
+
// subagent view, ←← returns to the main session). The second tap must land
|
|
77
|
+
// inside this window. The lower bound rejects terminal-synthesized arrow-key
|
|
78
|
+
// bursts: "click to move cursor" / pointer features in iTerm2, WezTerm, kitty,
|
|
79
|
+
// and tmux emit several arrow keys in a single stdin read (sub-millisecond
|
|
80
|
+
// apart) on a stray click, which used to pop the hub with no key ever pressed.
|
|
81
|
+
// Three or more rapid taps are likewise treated as a burst, not a gesture. A
|
|
82
|
+
// deliberate human double-tap is always tens of milliseconds apart.
|
|
83
|
+
const LEFT_DOUBLE_TAP_MIN_GAP_MS = 40;
|
|
84
|
+
const LEFT_DOUBLE_TAP_MAX_GAP_MS = 500;
|
|
51
85
|
|
|
52
86
|
export class InputController {
|
|
53
87
|
constructor(
|
|
@@ -61,6 +95,13 @@ export class InputController {
|
|
|
61
95
|
|
|
62
96
|
#enhancedPaste?: EnhancedPasteController;
|
|
63
97
|
#focusedLeftTapListenerInstalled = false;
|
|
98
|
+
// Tap counter for the double-← gesture; reset whenever a quiet gap
|
|
99
|
+
// (>= LEFT_DOUBLE_TAP_MAX_GAP_MS) starts a fresh sequence. See
|
|
100
|
+
// #detectLeftDoubleTap.
|
|
101
|
+
#leftTapCount = 0;
|
|
102
|
+
// Sequential index for `local://attachment-N` references created by the large-paste "attach as
|
|
103
|
+
// file" action. Seeded from 0 and bumped past any existing attachment files in #attachPasteAsFile.
|
|
104
|
+
#attachmentCounter = 0;
|
|
64
105
|
|
|
65
106
|
#showTinyTitleDownloadProgress(modelKey: string): void {
|
|
66
107
|
if (!isTinyTitleLocalModelKey(modelKey)) return;
|
|
@@ -270,6 +311,7 @@ export class InputController {
|
|
|
270
311
|
this.ctx.keybindings.getKeys("app.clipboard.pasteTextRaw"),
|
|
271
312
|
);
|
|
272
313
|
this.ctx.editor.onPasteTextRaw = () => void this.handleClipboardTextRawPaste();
|
|
314
|
+
this.ctx.editor.onLargePaste = (text, lineCount) => this.handleLargePaste(text, lineCount);
|
|
273
315
|
this.ctx.editor.setActionKeys(
|
|
274
316
|
"app.clipboard.copyPrompt",
|
|
275
317
|
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
|
|
@@ -305,6 +347,12 @@ export class InputController {
|
|
|
305
347
|
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
|
|
306
348
|
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
|
|
307
349
|
}
|
|
350
|
+
// Hold the space bar to push-to-talk: the editor recognizes the auto-repeat burst, tracks
|
|
351
|
+
// the spam back out, and toggles STT on hold start / release. Gated on `stt.enabled` so a
|
|
352
|
+
// disabled STT leaves the space bar typing normally.
|
|
353
|
+
this.ctx.editor.sttHoldEnabled = () => settings.get("stt.enabled");
|
|
354
|
+
this.ctx.editor.onSpaceHoldStart = () => void this.ctx.handleSTTToggle();
|
|
355
|
+
this.ctx.editor.onSpaceHoldEnd = () => void this.ctx.handleSTTToggle();
|
|
308
356
|
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
|
|
309
357
|
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
|
|
310
358
|
}
|
|
@@ -318,18 +366,16 @@ export class InputController {
|
|
|
318
366
|
|
|
319
367
|
// Double-tap left arrow on an empty editor: opens the agent hub from the
|
|
320
368
|
// main session, or returns the focused subagent view to the main session.
|
|
321
|
-
// Focused ←← intentionally matches Esc.
|
|
369
|
+
// Focused ←← intentionally matches Esc. From the main session the gesture
|
|
370
|
+
// stays inert when there are no subagents (requireContent); the explicit
|
|
371
|
+
// hub key still opens the empty roster.
|
|
322
372
|
this.ctx.editor.onLeftAtStart = () => {
|
|
323
373
|
if (this.ctx.focusedAgentId) {
|
|
324
374
|
this.#handleFocusedLeftTap();
|
|
325
375
|
return;
|
|
326
376
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
this.ctx.lastLeftTapTime = 0;
|
|
330
|
-
this.ctx.showAgentHub();
|
|
331
|
-
} else {
|
|
332
|
-
this.ctx.lastLeftTapTime = now;
|
|
377
|
+
if (this.#detectLeftDoubleTap()) {
|
|
378
|
+
this.ctx.showAgentHub({ requireContent: true });
|
|
333
379
|
}
|
|
334
380
|
};
|
|
335
381
|
|
|
@@ -348,13 +394,37 @@ export class InputController {
|
|
|
348
394
|
}
|
|
349
395
|
|
|
350
396
|
#handleFocusedLeftTap(): void {
|
|
397
|
+
if (this.#detectLeftDoubleTap()) {
|
|
398
|
+
void this.ctx.unfocusSession();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Detect a deliberate double-← gesture, rejecting terminal-synthesized arrow
|
|
404
|
+
* bursts. Returns true only on the *second* tap of a fresh sequence when it
|
|
405
|
+
* lands a human-plausible interval after the first
|
|
406
|
+
* (`[LEFT_DOUBLE_TAP_MIN_GAP_MS, LEFT_DOUBLE_TAP_MAX_GAP_MS)`). Taps closer
|
|
407
|
+
* than the lower bound, or any third-and-later tap before a quiet gap, are a
|
|
408
|
+
* burst and never fire — so a stray click that makes the terminal emit a run
|
|
409
|
+
* of ← keys can no longer pop the Agent Hub.
|
|
410
|
+
*/
|
|
411
|
+
#detectLeftDoubleTap(): boolean {
|
|
351
412
|
const now = Date.now();
|
|
352
|
-
|
|
413
|
+
const sinceLast = now - this.ctx.lastLeftTapTime;
|
|
414
|
+
this.ctx.lastLeftTapTime = now;
|
|
415
|
+
if (sinceLast >= LEFT_DOUBLE_TAP_MAX_GAP_MS) {
|
|
416
|
+
// Quiet gap: this tap starts a fresh sequence.
|
|
417
|
+
this.#leftTapCount = 1;
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
this.#leftTapCount += 1;
|
|
421
|
+
if (this.#leftTapCount === 2 && sinceLast >= LEFT_DOUBLE_TAP_MIN_GAP_MS) {
|
|
422
|
+
// Exactly two taps, the second a human-plausible interval after the first.
|
|
423
|
+
this.#leftTapCount = 0;
|
|
353
424
|
this.ctx.lastLeftTapTime = 0;
|
|
354
|
-
|
|
355
|
-
} else {
|
|
356
|
-
this.ctx.lastLeftTapTime = now;
|
|
425
|
+
return true;
|
|
357
426
|
}
|
|
427
|
+
return false;
|
|
358
428
|
}
|
|
359
429
|
|
|
360
430
|
#setupEnhancedPaste(): void {
|
|
@@ -924,7 +994,20 @@ export class InputController {
|
|
|
924
994
|
restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
|
|
925
995
|
this.ctx.locallySubmittedUserSignatures.clear();
|
|
926
996
|
const { steering, followUp } = this.ctx.session.clearQueue();
|
|
927
|
-
|
|
997
|
+
// Messages typed while compacting live in `compactionQueuedMessages`, not the
|
|
998
|
+
// agent queue `clearQueue()` drains — but the pending bar shows the same
|
|
999
|
+
// "Alt+Up to edit" hint for them (ui-helpers `updatePendingMessagesDisplay`).
|
|
1000
|
+
// Drain them here too so the dequeue restores every message the hint
|
|
1001
|
+
// advertises; otherwise a skill/text queued during compaction is stranded and
|
|
1002
|
+
// Alt+Up reports "No queued messages to restore".
|
|
1003
|
+
const compactionQueued = this.ctx.compactionQueuedMessages;
|
|
1004
|
+
this.ctx.compactionQueuedMessages = [];
|
|
1005
|
+
const allQueued = [
|
|
1006
|
+
...steering,
|
|
1007
|
+
...compactionQueued.filter(e => e.mode === "steer").map(e => ({ text: e.text, images: e.images })),
|
|
1008
|
+
...followUp,
|
|
1009
|
+
...compactionQueued.filter(e => e.mode === "followUp").map(e => ({ text: e.text, images: e.images })),
|
|
1010
|
+
];
|
|
928
1011
|
if (allQueued.length === 0) {
|
|
929
1012
|
this.ctx.updatePendingMessagesDisplay();
|
|
930
1013
|
if (options?.abort) {
|
|
@@ -932,14 +1015,34 @@ export class InputController {
|
|
|
932
1015
|
}
|
|
933
1016
|
return 0;
|
|
934
1017
|
}
|
|
935
|
-
|
|
1018
|
+
// Image markers are positional: `[Image #N]` ↔ `pendingImages[N-1]`. Each
|
|
1019
|
+
// queued message numbered its markers against its own local image list
|
|
1020
|
+
// (1..K). Because we prepend the queued text but append the queued images
|
|
1021
|
+
// to `pendingImages`, any existing draft images (M of them) — plus images
|
|
1022
|
+
// already pulled in by earlier queued messages — shift the slot index that
|
|
1023
|
+
// every marker must point to. Bumping each message's markers by the
|
|
1024
|
+
// running offset keeps the merged text aligned with the merged
|
|
1025
|
+
// `pendingImages` order; draft markers stay valid because draft images
|
|
1026
|
+
// keep their original positions.
|
|
1027
|
+
const queuedImages = allQueued.flatMap(e => e.images ?? []);
|
|
1028
|
+
let queuedText: string;
|
|
1029
|
+
if (queuedImages.length > 0) {
|
|
1030
|
+
const parts: string[] = [];
|
|
1031
|
+
let imageOffset = this.ctx.pendingImages.length;
|
|
1032
|
+
for (const entry of allQueued) {
|
|
1033
|
+
parts.push(shiftImageMarkers(entry.text, imageOffset));
|
|
1034
|
+
if (entry.images && entry.images.length > 0) imageOffset += entry.images.length;
|
|
1035
|
+
}
|
|
1036
|
+
queuedText = parts.join("\n\n");
|
|
1037
|
+
} else {
|
|
1038
|
+
queuedText = allQueued.map(e => e.text).join("\n\n");
|
|
1039
|
+
}
|
|
936
1040
|
const currentText = options?.currentText ?? this.ctx.editor.getText();
|
|
937
1041
|
const combinedText = [queuedText, currentText].filter(t => t.trim()).join("\n\n");
|
|
938
1042
|
this.ctx.editor.setText(combinedText);
|
|
939
1043
|
// Hand queued images back to the pending-image buffer (links are
|
|
940
1044
|
// re-materialized lazily; the restored text already carries the
|
|
941
|
-
// `[Image #N, WxH]` markers).
|
|
942
|
-
const queuedImages = allQueued.flatMap(e => e.images ?? []);
|
|
1045
|
+
// renumbered `[Image #N, WxH]` markers).
|
|
943
1046
|
if (queuedImages.length > 0) {
|
|
944
1047
|
this.ctx.pendingImages.push(...queuedImages);
|
|
945
1048
|
this.ctx.pendingImageLinks.push(...queuedImages.map(() => undefined));
|
|
@@ -1013,6 +1116,35 @@ export class InputController {
|
|
|
1013
1116
|
return true;
|
|
1014
1117
|
}
|
|
1015
1118
|
|
|
1119
|
+
/**
|
|
1120
|
+
* Win+Shift+S on Windows 11 leaves the screenshot bitmap on the clipboard
|
|
1121
|
+
* while the terminal pastes a transient packaged-app TempState path
|
|
1122
|
+
* (…\MicrosoftWindows.Client.Core_*\TempState\…) that is already gone — or
|
|
1123
|
+
* never materialized — by the time we read it. Whenever a pasted image path
|
|
1124
|
+
* can't be turned into an image locally, those clipboard bytes are the real
|
|
1125
|
+
* payload, so prefer them before degrading to a text paste.
|
|
1126
|
+
*
|
|
1127
|
+
* Skipped over SSH: the clipboard read would hit the remote host, not the
|
|
1128
|
+
* terminal that holds the screenshot. Returns true when the clipboard owned
|
|
1129
|
+
* the outcome (image attached, or an unsupported-format status surfaced), so
|
|
1130
|
+
* the caller stops without emitting its own degraded diagnostic.
|
|
1131
|
+
*/
|
|
1132
|
+
async #tryPasteClipboardImage(): Promise<boolean> {
|
|
1133
|
+
const env = process.env;
|
|
1134
|
+
if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return false;
|
|
1135
|
+
try {
|
|
1136
|
+
const image = await this.clipboard.readImage();
|
|
1137
|
+
if (!image) return false;
|
|
1138
|
+
await this.#normalizeAndInsertPastedImage(
|
|
1139
|
+
{ type: "image", data: image.data.toBase64(), mimeType: image.mimeType },
|
|
1140
|
+
`Unsupported clipboard image format: ${image.mimeType}`,
|
|
1141
|
+
);
|
|
1142
|
+
return true;
|
|
1143
|
+
} catch {
|
|
1144
|
+
return false;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1016
1148
|
async handleImagePathPaste(path: string): Promise<void> {
|
|
1017
1149
|
try {
|
|
1018
1150
|
const image = await loadImageInput({
|
|
@@ -1021,6 +1153,9 @@ export class InputController {
|
|
|
1021
1153
|
autoResize: false,
|
|
1022
1154
|
});
|
|
1023
1155
|
if (!image) {
|
|
1156
|
+
// Path resolved but is not a readable image (e.g. a zero-byte or
|
|
1157
|
+
// locked transient screenshot file). Prefer the clipboard bytes.
|
|
1158
|
+
if (await this.#tryPasteClipboardImage()) return;
|
|
1024
1159
|
this.ctx.editor.pasteText(path);
|
|
1025
1160
|
this.ctx.ui.requestRender();
|
|
1026
1161
|
this.ctx.showStatus("Pasted path is not a supported image");
|
|
@@ -1039,13 +1174,17 @@ export class InputController {
|
|
|
1039
1174
|
}
|
|
1040
1175
|
if (isEnoent(error)) {
|
|
1041
1176
|
// #2375: the bracketed paste forwarded by a local terminal carries a
|
|
1042
|
-
// path on the *local* filesystem.
|
|
1043
|
-
//
|
|
1044
|
-
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1177
|
+
// path on the *local* filesystem. The bytes may still be on the
|
|
1178
|
+
// clipboard (Win+Shift+S), so try those before giving up.
|
|
1179
|
+
if (await this.#tryPasteClipboardImage()) return;
|
|
1180
|
+
// Over SSH the clipboard lives on the remote host, so the path is
|
|
1181
|
+
// genuinely unreachable; pasting it as text would look like the
|
|
1182
|
+
// image was attached when nothing was sent. Surface an SSH-aware
|
|
1183
|
+
// diagnostic instead. The pasted path is untrusted terminal input —
|
|
1184
|
+
// strip control/ANSI/newlines, collapse home to `~`, and bound the
|
|
1185
|
+
// displayed length before splicing it into the status string.
|
|
1186
|
+
const env = process.env;
|
|
1187
|
+
const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
|
1049
1188
|
const displayPath = truncateToWidth(
|
|
1050
1189
|
shortenPath(
|
|
1051
1190
|
sanitizeText(path)
|
|
@@ -1054,8 +1193,6 @@ export class InputController {
|
|
|
1054
1193
|
),
|
|
1055
1194
|
TRUNCATE_LENGTHS.CONTENT,
|
|
1056
1195
|
);
|
|
1057
|
-
const env = process.env;
|
|
1058
|
-
const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
|
|
1059
1196
|
this.ctx.showStatus(
|
|
1060
1197
|
overSsh
|
|
1061
1198
|
? `Image not found at ${displayPath}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.`
|
|
@@ -1063,6 +1200,7 @@ export class InputController {
|
|
|
1063
1200
|
);
|
|
1064
1201
|
return;
|
|
1065
1202
|
}
|
|
1203
|
+
if (await this.#tryPasteClipboardImage()) return;
|
|
1066
1204
|
this.ctx.editor.pasteText(path);
|
|
1067
1205
|
this.ctx.ui.requestRender();
|
|
1068
1206
|
this.ctx.showStatus("Failed to read pasted image path");
|
|
@@ -1119,6 +1257,97 @@ export class InputController {
|
|
|
1119
1257
|
}
|
|
1120
1258
|
}
|
|
1121
1259
|
|
|
1260
|
+
/**
|
|
1261
|
+
* Editor `onLargePaste` hook: gate a marker-sized paste behind the large-paste menu. Returns
|
|
1262
|
+
* `true` to intercept (the editor skips its default `[Paste]` marker) once the paste reaches the
|
|
1263
|
+
* configured `paste.largeMenuThreshold` line count; otherwise `false` for default collapse-to-marker
|
|
1264
|
+
* behavior. The async menu is fired and forgotten — the editor only needs the synchronous verdict.
|
|
1265
|
+
*/
|
|
1266
|
+
handleLargePaste(text: string, lineCount: number): boolean {
|
|
1267
|
+
const threshold = this.ctx.settings.get("paste.largeMenuThreshold");
|
|
1268
|
+
if (!(threshold > 0) || lineCount < threshold) return false;
|
|
1269
|
+
void this.presentLargePasteMenu(text, lineCount);
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Present the large-paste menu and apply the chosen action: wrap in a code block or in XML tags
|
|
1275
|
+
* (both collapse to a `[Paste]` marker that expands on submit), or save the text to a file and
|
|
1276
|
+
* reference its path so the agent can `read` it on demand. Cancelling (Esc) falls back to the
|
|
1277
|
+
* default inline paste marker, so the pasted content is never lost.
|
|
1278
|
+
*/
|
|
1279
|
+
async presentLargePasteMenu(text: string, lineCount: number): Promise<void> {
|
|
1280
|
+
const CODE_BLOCK = "Wrap in a code block";
|
|
1281
|
+
const XML = "Wrap in XML tags";
|
|
1282
|
+
const FILE = "Attach as a file";
|
|
1283
|
+
|
|
1284
|
+
let choice: string | undefined;
|
|
1285
|
+
try {
|
|
1286
|
+
choice = await this.ctx.showHookSelector(
|
|
1287
|
+
`Pasted ${lineCount} lines`,
|
|
1288
|
+
[
|
|
1289
|
+
{ label: CODE_BLOCK, description: "Fence the text in a ``` block, collapsed to a marker" },
|
|
1290
|
+
{ label: XML, description: "Wrap the text in <pasted_text> tags, collapsed to a marker" },
|
|
1291
|
+
{ label: FILE, description: "Save the text to a file and reference its path" },
|
|
1292
|
+
],
|
|
1293
|
+
{ helpText: "Esc to paste inline" },
|
|
1294
|
+
);
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
logger.warn("large-paste menu failed", { error: error instanceof Error ? error.message : String(error) });
|
|
1297
|
+
choice = undefined;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
switch (choice) {
|
|
1301
|
+
case CODE_BLOCK:
|
|
1302
|
+
this.ctx.editor.insertPaste(wrapPasteInCodeBlock(text));
|
|
1303
|
+
break;
|
|
1304
|
+
case XML:
|
|
1305
|
+
this.ctx.editor.insertPaste(wrapPasteInXml(text));
|
|
1306
|
+
break;
|
|
1307
|
+
case FILE:
|
|
1308
|
+
await this.#attachPasteAsFile(text, lineCount);
|
|
1309
|
+
break;
|
|
1310
|
+
default:
|
|
1311
|
+
// Esc / cancel: keep the original behavior — collapse to an inline paste marker.
|
|
1312
|
+
this.ctx.editor.insertPaste(text);
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
this.ctx.ui.requestRender();
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* Save a large paste to the session's `local://` store and insert a clean `local://attachment-N`
|
|
1320
|
+
* reference into the editor so the agent can `read` it on demand — instead of inlining the text or
|
|
1321
|
+
* leaking a raw temp path. Falls back to an inline paste marker when the write fails, so the
|
|
1322
|
+
* content is never lost.
|
|
1323
|
+
*/
|
|
1324
|
+
async #attachPasteAsFile(text: string, lineCount: number): Promise<void> {
|
|
1325
|
+
try {
|
|
1326
|
+
// Mirror the exact mapping the read tool's local:// resolver uses so a later
|
|
1327
|
+
// `read local://attachment-N` lands on the file written here.
|
|
1328
|
+
const localRoot = resolveLocalRoot({
|
|
1329
|
+
getArtifactsDir: () => this.ctx.sessionManager.getArtifactsDir(),
|
|
1330
|
+
getSessionId: () => this.ctx.sessionManager.getSessionId(),
|
|
1331
|
+
});
|
|
1332
|
+
let name: string;
|
|
1333
|
+
let filePath: string;
|
|
1334
|
+
do {
|
|
1335
|
+
this.#attachmentCounter++;
|
|
1336
|
+
name = `attachment-${this.#attachmentCounter}`;
|
|
1337
|
+
filePath = path.join(localRoot, name);
|
|
1338
|
+
} while (await Bun.file(filePath).exists());
|
|
1339
|
+
await Bun.write(filePath, text);
|
|
1340
|
+
this.ctx.editor.insertText(`local://${name} `);
|
|
1341
|
+
this.ctx.showStatus(`Saved ${lineCount} pasted lines to local://${name}`);
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
logger.warn("failed to save large paste to file", {
|
|
1344
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1345
|
+
});
|
|
1346
|
+
this.ctx.editor.insertPaste(text);
|
|
1347
|
+
this.ctx.showError("Failed to save paste to a file — pasted inline instead");
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1122
1351
|
createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
|
|
1123
1352
|
return createPromptActionAutocompleteProvider({
|
|
1124
1353
|
commands,
|
|
@@ -1200,12 +1429,14 @@ export class InputController {
|
|
|
1200
1429
|
this.ctx.updateEditorBorderColor();
|
|
1201
1430
|
// The status line already reports the resolved model + thinking level, so
|
|
1202
1431
|
// the cycle status is just a status-line-style chip track (active role
|
|
1203
|
-
// filled), matching the plan-approval model slider.
|
|
1432
|
+
// filled), matching the plan-approval model slider. It renders into its
|
|
1433
|
+
// own anchored container above the editor (cleared+rebuilt each cycle),
|
|
1434
|
+
// so it updates in place instead of stacking duplicates in the scrollback.
|
|
1204
1435
|
const track = renderSegmentTrack(
|
|
1205
1436
|
cycleOrder.map(role => ({ label: role })),
|
|
1206
1437
|
cycleOrder.indexOf(result.role),
|
|
1207
1438
|
);
|
|
1208
|
-
this.ctx.
|
|
1439
|
+
this.ctx.showModelCycleTrack(track);
|
|
1209
1440
|
} catch (error) {
|
|
1210
1441
|
this.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
1211
1442
|
}
|
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
} from "../../modes/theme/theme";
|
|
29
29
|
import type { InteractiveModeContext } from "../../modes/types";
|
|
30
30
|
import type { ResetCreditRedeemOutcome } from "../../session/auth-storage";
|
|
31
|
-
import {
|
|
31
|
+
import type { SessionInfo } from "../../session/session-listing";
|
|
32
|
+
import { SessionManager } from "../../session/session-manager";
|
|
32
33
|
import { FileSessionStorage } from "../../session/session-storage";
|
|
33
34
|
import { type LogoutAccount, toLogoutAccounts } from "../../slash-commands/helpers/logout";
|
|
34
35
|
import {
|
|
@@ -1202,7 +1203,7 @@ export class SelectorController {
|
|
|
1202
1203
|
});
|
|
1203
1204
|
}
|
|
1204
1205
|
|
|
1205
|
-
showAgentHub(observers: SessionObserverRegistry): void {
|
|
1206
|
+
showAgentHub(observers: SessionObserverRegistry, options?: { requireContent?: boolean }): void {
|
|
1206
1207
|
const hubKeys = [
|
|
1207
1208
|
...this.ctx.keybindings.getKeys("app.agents.hub"),
|
|
1208
1209
|
...this.ctx.keybindings.getKeys("app.session.observe"),
|
|
@@ -1234,6 +1235,15 @@ export class SelectorController {
|
|
|
1234
1235
|
sessionFile: this.ctx.sessionManager.getSessionFile() ?? null,
|
|
1235
1236
|
});
|
|
1236
1237
|
|
|
1238
|
+
// The double-← gesture passes requireContent so it stays inert when there
|
|
1239
|
+
// are no subagents to show; the explicit hub/observe keys still open the
|
|
1240
|
+
// empty roster. The freshly built hub already ran the persisted-subagent
|
|
1241
|
+
// scan, so its row count is the authoritative "is there anything to show".
|
|
1242
|
+
if (options?.requireContent && hub.isEmpty) {
|
|
1243
|
+
hub.dispose();
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1237
1247
|
overlayHandle = this.ctx.ui.showOverlay(hub, {
|
|
1238
1248
|
anchor: "bottom-center",
|
|
1239
1249
|
width: "100%",
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { maskNonProse } from "./markdown-prose";
|
|
2
2
|
import { theme } from "./theme/theme";
|
|
3
3
|
|
|
4
|
-
/** A gradient keyword highlighter.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
|
|
4
|
+
/** A gradient keyword highlighter.
|
|
5
|
+
*
|
|
6
|
+
* - `resetTo` is the SGR foreground sequence re-emitted after each painted
|
|
7
|
+
* keyword so surrounding text keeps its color; it defaults to a plain
|
|
8
|
+
* foreground reset (editor / default-colored text).
|
|
9
|
+
* - `phase` ∈ [0, 1) rotates the gradient stops cyclically; pass `Date.now()`-
|
|
10
|
+
* derived values to animate a shimmer. Defaults to `0` (the static
|
|
11
|
+
* sent-bubble palette). */
|
|
12
|
+
export type KeywordHighlighter = (text: string, resetTo?: string, phase?: number) => string;
|
|
8
13
|
|
|
9
14
|
const FG_RESET = "\x1b[39m";
|
|
10
15
|
|
|
@@ -51,14 +56,19 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordH
|
|
|
51
56
|
return next;
|
|
52
57
|
};
|
|
53
58
|
|
|
54
|
-
/** Paint each character of `word` with the next gradient stop, restoring `resetTo` after.
|
|
55
|
-
|
|
59
|
+
/** Paint each character of `word` with the next gradient stop, restoring `resetTo` after.
|
|
60
|
+
* `phase` ∈ [0, 1) cyclically rotates the palette index so successive renders
|
|
61
|
+
* with monotonically increasing phase produce a moving shimmer; `0` yields the
|
|
62
|
+
* static palette. */
|
|
63
|
+
const paint = (word: string, resetTo: string, phase: number): string => {
|
|
56
64
|
const stopsArr = palette();
|
|
65
|
+
const m = stopsArr.length;
|
|
57
66
|
const n = word.length;
|
|
58
67
|
let out = "";
|
|
59
68
|
let prev = "";
|
|
60
69
|
for (let i = 0; i < n; i++) {
|
|
61
|
-
const
|
|
70
|
+
const t = (i / n + phase) % 1;
|
|
71
|
+
const color = stopsArr[Math.floor(t * m) % m] ?? stopsArr[0] ?? "";
|
|
62
72
|
// Coalesce consecutive characters that resolve to the same stop.
|
|
63
73
|
if (color !== prev) {
|
|
64
74
|
out += color;
|
|
@@ -69,8 +79,10 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordH
|
|
|
69
79
|
return `${out}${resetTo}`;
|
|
70
80
|
};
|
|
71
81
|
|
|
72
|
-
return (text: string, resetTo: string = FG_RESET): string => {
|
|
82
|
+
return (text: string, resetTo: string = FG_RESET, phase: number = 0): string => {
|
|
73
83
|
if (!probe.test(text)) return text;
|
|
84
|
+
// Wrap phase into [0, 1) so negative inputs and values ≥ 1 stay well-defined.
|
|
85
|
+
const wrappedPhase = ((phase % 1) + 1) % 1;
|
|
74
86
|
// Match against a code/markup-masked copy so keywords inside code spans,
|
|
75
87
|
// fenced blocks, or XML sections never paint; indices still address `text`.
|
|
76
88
|
const masked = maskNonProse(text);
|
|
@@ -79,7 +91,7 @@ export function createGradientHighlighter(spec: GradientHighlightSpec): KeywordH
|
|
|
79
91
|
for (const m of masked.matchAll(highlight)) {
|
|
80
92
|
const start = m.index ?? 0;
|
|
81
93
|
const end = start + m[0].length;
|
|
82
|
-
out += text.slice(last, start) + paint(text.slice(start, end), resetTo);
|
|
94
|
+
out += text.slice(last, start) + paint(text.slice(start, end), resetTo, wrappedPhase);
|
|
83
95
|
last = end;
|
|
84
96
|
}
|
|
85
97
|
return out + text.slice(last);
|
|
@@ -8,6 +8,26 @@ import { fileHyperlink } from "../tui/hyperlink";
|
|
|
8
8
|
* tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */
|
|
9
9
|
export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g;
|
|
10
10
|
|
|
11
|
+
/** Matches a single `[Image #N]` / `[Image #N, WxH]` marker. Group 1 is the
|
|
12
|
+
* 1-based index, group 2 the optional metadata tail (leading comma, no `]` or
|
|
13
|
+
* newline) so future label tweaks keep matching. Paste markers are excluded
|
|
14
|
+
* on purpose: their numbering is owned by the editor's paste store, not by
|
|
15
|
+
* the pending-image buffer. */
|
|
16
|
+
const IMAGE_MARKER_REGEX = /\[Image #([1-9]\d*)((?:,[^\]\n]*)?)\]/g;
|
|
17
|
+
|
|
18
|
+
/** Renumber every `[Image #N]` marker in `text` by `offset` (added to the
|
|
19
|
+
* existing index), preserving the optional `, WxH` tail. Paste markers are
|
|
20
|
+
* left untouched. Used when restoring queued image-messages back into a draft
|
|
21
|
+
* that already holds pending images so the merged text's positional markers
|
|
22
|
+
* still line up with `pendingImages`. */
|
|
23
|
+
export function shiftImageMarkers(text: string, offset: number): string {
|
|
24
|
+
if (offset === 0) return text;
|
|
25
|
+
return text.replace(
|
|
26
|
+
IMAGE_MARKER_REGEX,
|
|
27
|
+
(_match, idx: string, tail: string) => `[Image #${Number(idx) + offset}${tail}]`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
|
|
12
32
|
type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
|
|
13
33
|
|