@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.
Files changed (291) hide show
  1. package/CHANGELOG.md +304 -6
  2. package/dist/cli.js +1015 -881
  3. package/dist/types/async/job-manager.d.ts +15 -0
  4. package/dist/types/autolearn/controller.d.ts +25 -0
  5. package/dist/types/autolearn/managed-skills.d.ts +45 -0
  6. package/dist/types/autoresearch/state.d.ts +1 -1
  7. package/dist/types/autoresearch/types.d.ts +1 -1
  8. package/dist/types/cli/args.d.ts +19 -1
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -1
  11. package/dist/types/cli/setup-model-picker.d.ts +14 -0
  12. package/dist/types/collab/protocol.d.ts +1 -1
  13. package/dist/types/commands/say.d.ts +24 -0
  14. package/dist/types/config/keybindings.d.ts +3 -3
  15. package/dist/types/config/model-registry.d.ts +10 -0
  16. package/dist/types/config/models-config-schema.d.ts +12 -0
  17. package/dist/types/config/models-config.d.ts +8 -2
  18. package/dist/types/config/settings-schema.d.ts +261 -58
  19. package/dist/types/export/html/index.d.ts +2 -1
  20. package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +47 -1
  23. package/dist/types/extensibility/hooks/index.d.ts +2 -1
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
  25. package/dist/types/extensibility/plugins/loader.d.ts +11 -0
  26. package/dist/types/extensibility/shared-events.d.ts +1 -1
  27. package/dist/types/extensibility/skills.d.ts +10 -0
  28. package/dist/types/goals/guided-setup.d.ts +18 -0
  29. package/dist/types/goals/state.d.ts +1 -1
  30. package/dist/types/hindsight/transcript.d.ts +1 -1
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/internal-urls/local-protocol.d.ts +4 -2
  33. package/dist/types/main.d.ts +4 -3
  34. package/dist/types/mcp/startup-events.d.ts +11 -0
  35. package/dist/types/memories/index.d.ts +7 -0
  36. package/dist/types/memory-backend/local-backend.d.ts +4 -3
  37. package/dist/types/mnemopi/config.d.ts +4 -4
  38. package/dist/types/modes/components/agent-hub.d.ts +6 -0
  39. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  40. package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +39 -1
  42. package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
  43. package/dist/types/modes/components/session-selector.d.ts +1 -1
  44. package/dist/types/modes/components/tool-execution.d.ts +26 -16
  45. package/dist/types/modes/components/transcript-container.d.ts +23 -2
  46. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  47. package/dist/types/modes/components/usage-row.d.ts +3 -0
  48. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  49. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  51. package/dist/types/modes/gradient-highlight.d.ts +9 -4
  52. package/dist/types/modes/image-references.d.ts +6 -0
  53. package/dist/types/modes/interactive-mode.d.ts +27 -3
  54. package/dist/types/modes/magic-keywords.d.ts +13 -1
  55. package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
  56. package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
  57. package/dist/types/modes/runtime-init.d.ts +4 -0
  58. package/dist/types/modes/theme/theme.d.ts +13 -2
  59. package/dist/types/modes/types.d.ts +8 -2
  60. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  61. package/dist/types/registry/agent-registry.d.ts +17 -0
  62. package/dist/types/secrets/obfuscator.d.ts +1 -1
  63. package/dist/types/session/agent-session.d.ts +14 -2
  64. package/dist/types/session/indexed-session-storage.d.ts +3 -4
  65. package/dist/types/session/session-context.d.ts +39 -0
  66. package/dist/types/session/session-entries.d.ts +159 -0
  67. package/dist/types/session/session-listing.d.ts +69 -0
  68. package/dist/types/session/session-loader.d.ts +16 -0
  69. package/dist/types/session/session-manager.d.ts +82 -474
  70. package/dist/types/session/session-migrations.d.ts +12 -0
  71. package/dist/types/session/session-paths.d.ts +25 -0
  72. package/dist/types/session/session-persistence.d.ts +8 -0
  73. package/dist/types/session/session-storage.d.ts +11 -12
  74. package/dist/types/session/snapcompact-inline.d.ts +12 -1
  75. package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
  76. package/dist/types/session/tool-choice-queue.d.ts +6 -6
  77. package/dist/types/stt/asr-client.d.ts +90 -0
  78. package/dist/types/stt/asr-protocol.d.ts +97 -0
  79. package/dist/types/stt/asr-worker.d.ts +2 -0
  80. package/dist/types/stt/downloader.d.ts +38 -0
  81. package/dist/types/stt/endpointer.d.ts +59 -0
  82. package/dist/types/stt/index.d.ts +5 -1
  83. package/dist/types/stt/models.d.ts +120 -0
  84. package/dist/types/stt/recorder.d.ts +17 -0
  85. package/dist/types/stt/stt-controller.d.ts +6 -0
  86. package/dist/types/stt/transcriber.d.ts +5 -7
  87. package/dist/types/stt/wav.d.ts +29 -0
  88. package/dist/types/system-prompt.d.ts +4 -0
  89. package/dist/types/task/executor.d.ts +2 -0
  90. package/dist/types/task/index.d.ts +9 -1
  91. package/dist/types/task/types.d.ts +36 -0
  92. package/dist/types/tools/bash.d.ts +2 -2
  93. package/dist/types/tools/eval-render.d.ts +1 -1
  94. package/dist/types/tools/index.d.ts +11 -1
  95. package/dist/types/tools/irc.d.ts +1 -0
  96. package/dist/types/tools/learn.d.ts +51 -0
  97. package/dist/types/tools/manage-skill.d.ts +40 -0
  98. package/dist/types/tools/plan-mode-guard.d.ts +10 -0
  99. package/dist/types/tools/renderers.d.ts +7 -11
  100. package/dist/types/tools/ssh.d.ts +1 -1
  101. package/dist/types/tools/todo.d.ts +1 -1
  102. package/dist/types/tools/tts.d.ts +25 -0
  103. package/dist/types/tools/write.d.ts +1 -1
  104. package/dist/types/tts/downloader.d.ts +20 -0
  105. package/dist/types/tts/index.d.ts +8 -0
  106. package/dist/types/tts/models.d.ts +82 -0
  107. package/dist/types/tts/player.d.ts +32 -0
  108. package/dist/types/tts/runtime.d.ts +6 -0
  109. package/dist/types/tts/streaming-player.d.ts +41 -0
  110. package/dist/types/tts/tts-client.d.ts +93 -0
  111. package/dist/types/tts/tts-protocol.d.ts +95 -0
  112. package/dist/types/tts/tts-worker.d.ts +2 -0
  113. package/dist/types/tts/vocalizer.d.ts +41 -0
  114. package/dist/types/tts/wav.d.ts +8 -0
  115. package/dist/types/utils/tool-choice.d.ts +8 -0
  116. package/dist/types/utils/tools-manager.d.ts +2 -1
  117. package/dist/types/utils/tools-manager.test.d.ts +1 -0
  118. package/dist/types/web/scrapers/github.d.ts +1 -1
  119. package/package.json +15 -14
  120. package/src/async/job-manager.ts +49 -0
  121. package/src/autolearn/controller.ts +139 -0
  122. package/src/autolearn/managed-skills.ts +257 -0
  123. package/src/autoresearch/state.ts +1 -1
  124. package/src/autoresearch/types.ts +1 -1
  125. package/src/cli/args.ts +56 -2
  126. package/src/cli/session-picker.ts +2 -1
  127. package/src/cli/setup-cli.ts +148 -47
  128. package/src/cli/setup-model-picker.ts +43 -0
  129. package/src/cli-commands.ts +1 -0
  130. package/src/cli.ts +45 -13
  131. package/src/collab/host.ts +1 -1
  132. package/src/collab/protocol.ts +1 -1
  133. package/src/commands/say.ts +102 -0
  134. package/src/commands/setup.ts +1 -1
  135. package/src/commit/agentic/tools/analyze-file.ts +3 -0
  136. package/src/config/keybindings.ts +2 -2
  137. package/src/config/model-discovery.ts +11 -5
  138. package/src/config/model-registry.ts +64 -9
  139. package/src/config/models-config-schema.ts +4 -1
  140. package/src/config/models-config.ts +2 -1
  141. package/src/config/settings-schema.ts +248 -32
  142. package/src/config/settings.ts +10 -0
  143. package/src/discovery/builtin.ts +23 -1
  144. package/src/discovery/claude-plugins.ts +44 -5
  145. package/src/discovery/helpers.ts +41 -1
  146. package/src/eval/__tests__/budget-bridge.test.ts +1 -1
  147. package/src/eval/js/shared/prelude.txt +69 -17
  148. package/src/export/html/index.ts +3 -6
  149. package/src/extensibility/extensions/model-api.ts +41 -0
  150. package/src/extensibility/extensions/runner.ts +4 -0
  151. package/src/extensibility/extensions/types.ts +52 -1
  152. package/src/extensibility/extensions/wrapper.ts +41 -5
  153. package/src/extensibility/hooks/index.ts +2 -1
  154. package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
  155. package/src/extensibility/plugins/loader.ts +30 -19
  156. package/src/extensibility/plugins/manager.ts +221 -90
  157. package/src/extensibility/shared-events.ts +1 -1
  158. package/src/extensibility/skills.ts +96 -15
  159. package/src/goals/guided-setup.ts +133 -0
  160. package/src/goals/state.ts +1 -1
  161. package/src/hindsight/transcript.ts +1 -1
  162. package/src/index.ts +5 -0
  163. package/src/internal-urls/docs-index.generated.ts +10 -10
  164. package/src/internal-urls/history-protocol.ts +1 -1
  165. package/src/internal-urls/local-protocol.ts +29 -7
  166. package/src/main.ts +27 -7
  167. package/src/mcp/startup-events.ts +21 -0
  168. package/src/mcp/transports/stdio.ts +2 -1
  169. package/src/memories/index.ts +146 -11
  170. package/src/memory-backend/local-backend.ts +11 -5
  171. package/src/mnemopi/backend.ts +1 -0
  172. package/src/mnemopi/config.ts +26 -10
  173. package/src/modes/acp/acp-agent.ts +3 -5
  174. package/src/modes/components/agent-hub.ts +49 -4
  175. package/src/modes/components/assistant-message.ts +4 -37
  176. package/src/modes/components/compaction-summary-message.ts +125 -26
  177. package/src/modes/components/custom-editor.test.ts +96 -0
  178. package/src/modes/components/custom-editor.ts +164 -8
  179. package/src/modes/components/session-selector.ts +1 -1
  180. package/src/modes/components/settings-defs.ts +7 -0
  181. package/src/modes/components/tool-execution.ts +82 -43
  182. package/src/modes/components/transcript-container.ts +70 -1
  183. package/src/modes/components/tree-selector.ts +1 -1
  184. package/src/modes/components/usage-row.ts +18 -0
  185. package/src/modes/components/user-message.ts +4 -2
  186. package/src/modes/controllers/command-controller.ts +14 -4
  187. package/src/modes/controllers/event-controller.ts +78 -11
  188. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  189. package/src/modes/controllers/input-controller.ts +258 -27
  190. package/src/modes/controllers/selector-controller.ts +12 -2
  191. package/src/modes/gradient-highlight.ts +21 -9
  192. package/src/modes/image-references.ts +20 -0
  193. package/src/modes/interactive-mode.ts +286 -40
  194. package/src/modes/magic-keywords.ts +27 -5
  195. package/src/modes/rpc/rpc-mode.ts +146 -14
  196. package/src/modes/rpc/rpc-subagents.ts +2 -2
  197. package/src/modes/rpc/rpc-types.ts +8 -2
  198. package/src/modes/runtime-init.ts +28 -3
  199. package/src/modes/theme/theme.ts +98 -50
  200. package/src/modes/types.ts +6 -2
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  202. package/src/modes/utils/ui-helpers.ts +34 -6
  203. package/src/priority.json +5 -1
  204. package/src/prompts/agents/task.md +1 -0
  205. package/src/prompts/goals/guided-goal-interview.md +8 -0
  206. package/src/prompts/goals/guided-goal-system.md +12 -0
  207. package/src/prompts/memories/read-path.md +6 -0
  208. package/src/prompts/system/autolearn-guidance-learn.md +1 -0
  209. package/src/prompts/system/autolearn-guidance.md +7 -0
  210. package/src/prompts/system/autolearn-nudge.md +3 -0
  211. package/src/prompts/system/eager-task.md +7 -0
  212. package/src/prompts/system/eager-todo.md +11 -6
  213. package/src/prompts/system/subagent-system-prompt.md +4 -0
  214. package/src/prompts/system/system-prompt.md +10 -5
  215. package/src/prompts/system/title-marker-instruction.md +1 -0
  216. package/src/prompts/system/title-system-marker.md +16 -0
  217. package/src/prompts/tools/job.md +1 -0
  218. package/src/prompts/tools/learn.md +7 -0
  219. package/src/prompts/tools/manage-skill.md +9 -0
  220. package/src/prompts/tools/task.md +3 -0
  221. package/src/registry/agent-registry.ts +30 -0
  222. package/src/sdk.ts +88 -24
  223. package/src/secrets/obfuscator.ts +1 -1
  224. package/src/session/agent-session.ts +209 -87
  225. package/src/session/history-storage.ts +2 -2
  226. package/src/session/indexed-session-storage.ts +7 -17
  227. package/src/session/session-context.ts +352 -0
  228. package/src/session/session-entries.ts +194 -0
  229. package/src/session/session-listing.ts +588 -0
  230. package/src/session/session-loader.ts +106 -0
  231. package/src/session/session-manager.ts +933 -3145
  232. package/src/session/session-migrations.ts +78 -0
  233. package/src/session/session-paths.ts +193 -0
  234. package/src/session/session-persistence.ts +131 -0
  235. package/src/session/session-storage.ts +91 -50
  236. package/src/session/snapcompact-inline.ts +21 -1
  237. package/src/session/snapcompact-savings-journal.ts +113 -0
  238. package/src/session/tool-choice-queue.ts +23 -11
  239. package/src/slash-commands/builtin-registry.ts +25 -3
  240. package/src/stt/asr-client.ts +520 -0
  241. package/src/stt/asr-protocol.ts +65 -0
  242. package/src/stt/asr-worker.ts +790 -0
  243. package/src/stt/downloader.ts +107 -47
  244. package/src/stt/endpointer.ts +259 -0
  245. package/src/stt/index.ts +5 -1
  246. package/src/stt/models.ts +150 -0
  247. package/src/stt/recorder.ts +247 -60
  248. package/src/stt/stt-controller.ts +201 -22
  249. package/src/stt/transcriber.ts +37 -68
  250. package/src/stt/wav.ts +173 -0
  251. package/src/system-prompt.ts +8 -0
  252. package/src/task/agents.ts +1 -2
  253. package/src/task/executor.ts +49 -15
  254. package/src/task/index.ts +60 -6
  255. package/src/task/render.ts +83 -8
  256. package/src/task/types.ts +53 -0
  257. package/src/tools/ask.ts +8 -0
  258. package/src/tools/bash.ts +4 -3
  259. package/src/tools/eval-render.ts +4 -3
  260. package/src/tools/index.ts +40 -4
  261. package/src/tools/irc.ts +10 -2
  262. package/src/tools/job.ts +14 -2
  263. package/src/tools/learn.ts +144 -0
  264. package/src/tools/manage-skill.ts +104 -0
  265. package/src/tools/plan-mode-guard.ts +53 -19
  266. package/src/tools/renderers.ts +7 -11
  267. package/src/tools/ssh.ts +4 -3
  268. package/src/tools/todo.ts +1 -1
  269. package/src/tools/tts.ts +203 -92
  270. package/src/tools/write.ts +18 -2
  271. package/src/tts/downloader.ts +64 -0
  272. package/src/tts/index.ts +8 -0
  273. package/src/tts/models.ts +137 -0
  274. package/src/tts/player.ts +137 -0
  275. package/src/tts/runtime.ts +21 -0
  276. package/src/tts/streaming-player.ts +266 -0
  277. package/src/tts/tts-client.ts +647 -0
  278. package/src/tts/tts-protocol.ts +60 -0
  279. package/src/tts/tts-worker.ts +497 -0
  280. package/src/tts/vocalizer.ts +162 -0
  281. package/src/tts/wav.ts +58 -0
  282. package/src/utils/title-generator.ts +48 -5
  283. package/src/utils/tool-choice.ts +16 -0
  284. package/src/utils/tools-manager.test.ts +25 -0
  285. package/src/utils/tools-manager.ts +19 -1
  286. package/src/web/scrapers/github.ts +96 -0
  287. package/src/web/search/index.ts +13 -0
  288. package/src/web/search/providers/searxng.ts +13 -1
  289. package/dist/types/stt/setup.d.ts +0 -18
  290. package/src/stt/setup.ts +0 -52
  291. 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
- const now = Date.now();
328
- if (now - this.ctx.lastLeftTapTime < 500) {
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
- if (now - this.ctx.lastLeftTapTime < 500) {
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
- void this.ctx.unfocusSession();
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
- const allQueued = [...steering, ...followUp];
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
- const queuedText = allQueued.map(e => e.text).join("\n\n");
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. When omp itself runs over SSH, that
1043
- // path is unreachable here; pasting it as text would look like the
1044
- // image was attached when in fact nothing was sent. Refuse the silent
1045
- // degrade and tell the user how to send the bytes for real. The
1046
- // pasted path is untrusted terminal input strip control/ANSI/
1047
- // newlines, collapse home to `~`, and bound the displayed length
1048
- // before splicing it into the status string.
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.showStatus(track, { dim: false });
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 { type SessionInfo, SessionManager } from "../../session/session-manager";
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. `resetTo` is the SGR foreground sequence
5
- * re-emitted after each painted keyword so surrounding text keeps its color;
6
- * it defaults to a plain foreground reset (editor / default-colored text). */
7
- export type KeywordHighlighter = (text: string, resetTo?: string) => string;
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
- const paint = (word: string, resetTo: string): string => {
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 color = stopsArr[Math.floor((i / n) * stopsArr.length)] ?? stopsArr[0] ?? "";
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