mindweave 2.5.0 → 3.0.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 (255) hide show
  1. package/README.md +81 -145
  2. package/dist/cli/App.js +579 -304
  3. package/dist/cli/App.js.map +1 -1
  4. package/dist/cli/analytics.js +27 -18
  5. package/dist/cli/analytics.js.map +1 -1
  6. package/dist/cli/attachments.js +34 -0
  7. package/dist/cli/attachments.js.map +1 -1
  8. package/dist/cli/bannerFit.js +70 -0
  9. package/dist/cli/bannerFit.js.map +1 -0
  10. package/dist/cli/bootstrap.js +41 -3
  11. package/dist/cli/bootstrap.js.map +1 -1
  12. package/dist/cli/commands.js +4 -1
  13. package/dist/cli/commands.js.map +1 -1
  14. package/dist/cli/components/ApprovalBox.js +3 -2
  15. package/dist/cli/components/ApprovalBox.js.map +1 -1
  16. package/dist/cli/components/BlockView.js +27 -6
  17. package/dist/cli/components/BlockView.js.map +1 -1
  18. package/dist/cli/components/FirstRunFrame.js +2 -1
  19. package/dist/cli/components/FirstRunFrame.js.map +1 -1
  20. package/dist/cli/components/KeyManager.js +5 -4
  21. package/dist/cli/components/KeyManager.js.map +1 -1
  22. package/dist/cli/components/KeySetup.js +29 -13
  23. package/dist/cli/components/KeySetup.js.map +1 -1
  24. package/dist/cli/components/MarathonBox.js +64 -0
  25. package/dist/cli/components/MarathonBox.js.map +1 -0
  26. package/dist/cli/components/McpMinitabs.js +8 -7
  27. package/dist/cli/components/McpMinitabs.js.map +1 -1
  28. package/dist/cli/components/Picker.js +4 -3
  29. package/dist/cli/components/Picker.js.map +1 -1
  30. package/dist/cli/components/PromptInput.js +47 -8
  31. package/dist/cli/components/PromptInput.js.map +1 -1
  32. package/dist/cli/components/PulseDot.js +42 -0
  33. package/dist/cli/components/PulseDot.js.map +1 -0
  34. package/dist/cli/components/SubagentView.js +9 -6
  35. package/dist/cli/components/SubagentView.js.map +1 -1
  36. package/dist/cli/components/TipLine.js +2 -1
  37. package/dist/cli/components/TipLine.js.map +1 -1
  38. package/dist/cli/components/ToolGroup.js +65 -49
  39. package/dist/cli/components/ToolGroup.js.map +1 -1
  40. package/dist/cli/components/ToolLine.js +20 -19
  41. package/dist/cli/components/ToolLine.js.map +1 -1
  42. package/dist/cli/components/TrustGate.js +2 -1
  43. package/dist/cli/components/TrustGate.js.map +1 -1
  44. package/dist/cli/components/minitabs.js +3 -2
  45. package/dist/cli/components/minitabs.js.map +1 -1
  46. package/dist/cli/desktopApp.js +45 -0
  47. package/dist/cli/desktopApp.js.map +1 -0
  48. package/dist/cli/help.js +1 -0
  49. package/dist/cli/help.js.map +1 -1
  50. package/dist/cli/inkOutputPatch.js +285 -0
  51. package/dist/cli/inkOutputPatch.js.map +1 -0
  52. package/dist/cli/inkSpeed.js +29 -0
  53. package/dist/cli/inkSpeed.js.map +1 -0
  54. package/dist/cli/inkSpeedLoader.js +47 -0
  55. package/dist/cli/inkSpeedLoader.js.map +1 -0
  56. package/dist/cli/inputText.js +30 -0
  57. package/dist/cli/inputText.js.map +1 -0
  58. package/dist/cli/keyManager.js +2 -1
  59. package/dist/cli/keyManager.js.map +1 -1
  60. package/dist/cli/keyPrefs.js +90 -0
  61. package/dist/cli/keyPrefs.js.map +1 -0
  62. package/dist/cli/keySetup.js +5 -2
  63. package/dist/cli/keySetup.js.map +1 -1
  64. package/dist/cli/marathonUi.js +113 -0
  65. package/dist/cli/marathonUi.js.map +1 -0
  66. package/dist/cli/markdown.js +63 -11
  67. package/dist/cli/markdown.js.map +1 -1
  68. package/dist/cli/modes.js +3 -3
  69. package/dist/cli/modes.js.map +1 -1
  70. package/dist/cli/prodEnv.js +43 -0
  71. package/dist/cli/prodEnv.js.map +1 -0
  72. package/dist/cli/revealPace.js +5 -5
  73. package/dist/cli/revealPace.js.map +1 -1
  74. package/dist/cli/revealQueue.js +120 -0
  75. package/dist/cli/revealQueue.js.map +1 -0
  76. package/dist/cli/theme.js +47 -0
  77. package/dist/cli/theme.js.map +1 -0
  78. package/dist/cli/toolDisplay.js +112 -18
  79. package/dist/cli/toolDisplay.js.map +1 -1
  80. package/dist/cli/transcript.js +1 -1
  81. package/dist/cli/transcript.js.map +1 -1
  82. package/dist/cli/virtualWindow.js +14 -2
  83. package/dist/cli/virtualWindow.js.map +1 -1
  84. package/dist/cli/wheelAccel.js +73 -0
  85. package/dist/cli/wheelAccel.js.map +1 -0
  86. package/dist/cli/widestLinePatch.js +48 -0
  87. package/dist/cli/widestLinePatch.js.map +1 -0
  88. package/dist/cli/workingVerb.js +88 -28
  89. package/dist/cli/workingVerb.js.map +1 -1
  90. package/dist/cli/wrap.js +1 -1
  91. package/dist/cli/wrap.js.map +1 -1
  92. package/dist/core/contextSettings.js +84 -0
  93. package/dist/core/contextSettings.js.map +1 -0
  94. package/dist/core/limitsView.js +40 -0
  95. package/dist/core/limitsView.js.map +1 -0
  96. package/dist/core/mcpServers.js +164 -0
  97. package/dist/core/mcpServers.js.map +1 -0
  98. package/dist/core/permissions.js +209 -0
  99. package/dist/core/permissions.js.map +1 -0
  100. package/dist/core/providerKeys.js +187 -0
  101. package/dist/core/providerKeys.js.map +1 -0
  102. package/dist/core/rulesSkills.js +142 -0
  103. package/dist/core/rulesSkills.js.map +1 -0
  104. package/dist/core/spendView.js +90 -0
  105. package/dist/core/spendView.js.map +1 -0
  106. package/dist/core/turnRunner.js +759 -0
  107. package/dist/core/turnRunner.js.map +1 -0
  108. package/dist/drivers/anthropic/client.js +101 -26
  109. package/dist/drivers/anthropic/client.js.map +1 -1
  110. package/dist/drivers/anthropic/manifest.js +43 -13
  111. package/dist/drivers/anthropic/manifest.js.map +1 -1
  112. package/dist/drivers/cerebras/manifest.js +13 -3
  113. package/dist/drivers/cerebras/manifest.js.map +1 -1
  114. package/dist/drivers/deepseek/manifest.js +24 -34
  115. package/dist/drivers/deepseek/manifest.js.map +1 -1
  116. package/dist/drivers/glm/manifest.js +21 -6
  117. package/dist/drivers/glm/manifest.js.map +1 -1
  118. package/dist/drivers/groq/manifest.js +5 -2
  119. package/dist/drivers/groq/manifest.js.map +1 -1
  120. package/dist/drivers/kimi/manifest.js +13 -10
  121. package/dist/drivers/kimi/manifest.js.map +1 -1
  122. package/dist/drivers/meta/manifest.js +21 -16
  123. package/dist/drivers/meta/manifest.js.map +1 -1
  124. package/dist/drivers/minimax/manifest.js +15 -12
  125. package/dist/drivers/minimax/manifest.js.map +1 -1
  126. package/dist/drivers/ollama/catalog.js +71 -0
  127. package/dist/drivers/ollama/catalog.js.map +1 -0
  128. package/dist/drivers/ollama/client.js +164 -0
  129. package/dist/drivers/ollama/client.js.map +1 -0
  130. package/dist/drivers/ollama/endpoint.js +42 -0
  131. package/dist/drivers/ollama/endpoint.js.map +1 -0
  132. package/dist/drivers/ollama/index.js +5 -0
  133. package/dist/drivers/ollama/index.js.map +1 -0
  134. package/dist/drivers/ollama/manifest.js +64 -0
  135. package/dist/drivers/ollama/manifest.js.map +1 -0
  136. package/dist/drivers/openai/client.js +10 -4
  137. package/dist/drivers/openai/client.js.map +1 -1
  138. package/dist/drivers/openai/manifest.js +69 -18
  139. package/dist/drivers/openai/manifest.js.map +1 -1
  140. package/dist/drivers/qwen/manifest.js +19 -6
  141. package/dist/drivers/qwen/manifest.js.map +1 -1
  142. package/dist/drivers/registry.js +11 -0
  143. package/dist/drivers/registry.js.map +1 -1
  144. package/dist/drivers/retryPolicy.js +26 -1
  145. package/dist/drivers/retryPolicy.js.map +1 -1
  146. package/dist/drivers/xai/client.js +12 -10
  147. package/dist/drivers/xai/client.js.map +1 -1
  148. package/dist/drivers/xai/manifest.js +62 -25
  149. package/dist/drivers/xai/manifest.js.map +1 -1
  150. package/dist/dynamo/auxModel.js +54 -0
  151. package/dist/dynamo/auxModel.js.map +1 -0
  152. package/dist/dynamo/engine.js +407 -99
  153. package/dist/dynamo/engine.js.map +1 -1
  154. package/dist/dynamo/guard.js +10 -0
  155. package/dist/dynamo/guard.js.map +1 -1
  156. package/dist/dynamo/liveMeter.js +25 -6
  157. package/dist/dynamo/liveMeter.js.map +1 -1
  158. package/dist/dynamo/marathon.js +409 -0
  159. package/dist/dynamo/marathon.js.map +1 -0
  160. package/dist/dynamo/marathonPrompt.js +45 -0
  161. package/dist/dynamo/marathonPrompt.js.map +1 -0
  162. package/dist/dynamo/model.js +6 -2
  163. package/dist/dynamo/model.js.map +1 -1
  164. package/dist/dynamo/toolBatches.js +58 -0
  165. package/dist/dynamo/toolBatches.js.map +1 -0
  166. package/dist/dynamo/usageLimits.js +446 -0
  167. package/dist/dynamo/usageLimits.js.map +1 -0
  168. package/dist/governor/freshness.js +10 -7
  169. package/dist/governor/freshness.js.map +1 -1
  170. package/dist/governor/index.js +76 -9
  171. package/dist/governor/index.js.map +1 -1
  172. package/dist/governor/write.js +43 -14
  173. package/dist/governor/write.js.map +1 -1
  174. package/dist/index.js +18 -151
  175. package/dist/index.js.map +1 -1
  176. package/dist/main.js +162 -0
  177. package/dist/main.js.map +1 -0
  178. package/dist/mcp/manager.js +18 -0
  179. package/dist/mcp/manager.js.map +1 -1
  180. package/dist/memory/allMetas.js +69 -0
  181. package/dist/memory/allMetas.js.map +1 -0
  182. package/dist/memory/compaction.js +70 -15
  183. package/dist/memory/compaction.js.map +1 -1
  184. package/dist/memory/earlier.js +15 -0
  185. package/dist/memory/earlier.js.map +1 -0
  186. package/dist/memory/profile.js +93 -0
  187. package/dist/memory/profile.js.map +1 -0
  188. package/dist/memory/rewind.js +262 -0
  189. package/dist/memory/rewind.js.map +1 -0
  190. package/dist/memory/session.js +42 -10
  191. package/dist/memory/session.js.map +1 -1
  192. package/dist/memory/sessionMemory.js +48 -13
  193. package/dist/memory/sessionMemory.js.map +1 -1
  194. package/dist/memory/sessionMemoryCompact.js +1 -1
  195. package/dist/memory/sessionMemoryCompact.js.map +1 -1
  196. package/dist/memory/sessionTitle.js +50 -0
  197. package/dist/memory/sessionTitle.js.map +1 -0
  198. package/dist/memory/store.js +42 -1
  199. package/dist/memory/store.js.map +1 -1
  200. package/dist/memory/types.js.map +1 -1
  201. package/dist/tools/askUser.js +14 -0
  202. package/dist/tools/askUser.js.map +1 -1
  203. package/dist/tools/backgroundShells.js +208 -5
  204. package/dist/tools/backgroundShells.js.map +1 -1
  205. package/dist/tools/browser.js +176 -0
  206. package/dist/tools/browser.js.map +1 -0
  207. package/dist/tools/cdp.js +165 -0
  208. package/dist/tools/cdp.js.map +1 -0
  209. package/dist/tools/checkpoints.js +208 -5
  210. package/dist/tools/checkpoints.js.map +1 -1
  211. package/dist/tools/deferredNative.js +2 -0
  212. package/dist/tools/deferredNative.js.map +1 -1
  213. package/dist/tools/detail.js +9 -6
  214. package/dist/tools/detail.js.map +1 -1
  215. package/dist/tools/edit.js +2 -1
  216. package/dist/tools/edit.js.map +1 -1
  217. package/dist/tools/exitPlan.js +4 -0
  218. package/dist/tools/exitPlan.js.map +1 -1
  219. package/dist/tools/hiddenDesktop.js +273 -0
  220. package/dist/tools/hiddenDesktop.js.map +1 -0
  221. package/dist/tools/listeningPorts.js +134 -0
  222. package/dist/tools/listeningPorts.js.map +1 -0
  223. package/dist/tools/mcpSearch.js +8 -0
  224. package/dist/tools/mcpSearch.js.map +1 -1
  225. package/dist/tools/mindweaveStatus.js +1 -1
  226. package/dist/tools/mindweaveStatus.js.map +1 -1
  227. package/dist/tools/registry.js +3 -0
  228. package/dist/tools/registry.js.map +1 -1
  229. package/dist/tools/replaceSymbol.js +2 -1
  230. package/dist/tools/replaceSymbol.js.map +1 -1
  231. package/dist/tools/runCommand.js +113 -9
  232. package/dist/tools/runCommand.js.map +1 -1
  233. package/dist/tools/screenshot.js +4 -6
  234. package/dist/tools/screenshot.js.map +1 -1
  235. package/dist/tools/screenshotWin.js +26 -9
  236. package/dist/tools/screenshotWin.js.map +1 -1
  237. package/dist/tools/stateCheckpoint.js +128 -0
  238. package/dist/tools/stateCheckpoint.js.map +1 -0
  239. package/dist/tools/todo.js +6 -0
  240. package/dist/tools/todo.js.map +1 -1
  241. package/dist/tools/ui.js +1295 -0
  242. package/dist/tools/ui.js.map +1 -0
  243. package/dist/tools/uiHide.js +356 -0
  244. package/dist/tools/uiHide.js.map +1 -0
  245. package/dist/tools/uiPage.js +861 -0
  246. package/dist/tools/uiPage.js.map +1 -0
  247. package/dist/tools/uiWin.js +506 -0
  248. package/dist/tools/uiWin.js.map +1 -0
  249. package/dist/tools/webFetch.js +32 -25
  250. package/dist/tools/webFetch.js.map +1 -1
  251. package/dist/tools/webSearch.js +12 -0
  252. package/dist/tools/webSearch.js.map +1 -1
  253. package/dist/tools/writeFile.js +2 -1
  254. package/dist/tools/writeFile.js.map +1 -1
  255. package/package.json +78 -75
package/dist/cli/App.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * App — the terminal UI shell (the "eyes and hands" of Mindweave).
4
4
  *
@@ -24,14 +24,16 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
24
24
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
25
25
  import { isAbsolute, resolve } from "node:path";
26
26
  import { Box, Static, Text, measureElement, useApp, useInput, useStdout } from "ink";
27
- import { compactNow, contextUsed, respond } from "../dynamo/engine.js";
28
- import { contextPressure, sharpContextWindow, autoCompactThreshold } from "../dynamo/contextWindow.js";
27
+ import { compactNow, contextUsed, effectiveAutoCompactThreshold, respond } from "../dynamo/engine.js";
28
+ import { contextPressure, sharpContextWindow } from "../dynamo/contextWindow.js";
29
29
  import { contextBudget, formatBudget } from "../dynamo/contextBudget.js";
30
+ import { contextView, resetContextOverride, setContextOverride } from "../core/contextSettings.js";
30
31
  import { createSession, resumeSession, reloadProjectMemory } from "../memory/session.js";
31
32
  import { saveSession, listSessions } from "../memory/store.js";
32
33
  import { stopChassis } from "../alternator/lane.js";
33
34
  import { loadSkillBody, substituteSkillArgs } from "../governor/skills.js";
34
35
  import { appendForbidden, appendForbiddenCommand } from "../governor/write.js";
36
+ import { stopUi } from "../tools/ui.js";
35
37
  import { addRoot, removeRoot } from "../tools/workspace.js";
36
38
  import { discoverRelatedRoots } from "../tools/workspaceDiscover.js";
37
39
  import { rootLabel, rootsOf, relativize } from "../tools/paths.js";
@@ -45,8 +47,9 @@ import { providerRows, keyRowsFor, nextSlotFor } from "./keyManager.js";
45
47
  import { keysFor } from "./keyStore.js";
46
48
  import { TrustGate } from "./components/TrustGate.js";
47
49
  import { rootBreadth, breadthWarning, trustPersists, isTrusted, rememberTrust } from "./trust.js";
48
- import { projectDir } from "../memory/store.js";
50
+ import { checkpointDir, projectDir } from "../memory/store.js";
49
51
  import { parseUndoArg, undoNotice } from "../tools/checkpoints.js";
52
+ import { rewindPoints, rewindTo, pasteSlot, reloadAgentState } from "../memory/rewind.js";
50
53
  import { DEFAULT_MODEL_CONFIG, thinkLevels, thinkLabel, modelLabel, modelsOfProvider, providerOf, usableFallback, needsKeySetup, withModel, saveModelConfig, refreshModels, DISCOVERY_TTL_MS } from "../dynamo/model.js";
51
54
  import { allProviders, manifestForModel, modelsOf } from "../drivers/registry.js";
52
55
  import { orderProviders, orderModels } from "./pickerOrder.js";
@@ -61,7 +64,8 @@ import { formatHelp } from "./help.js";
61
64
  import { hasApiKey, saveApiKey, removeApiKey, useApiKey, globalEnvPath, reloadConfig } from "./bootstrap.js";
62
65
  import { versionLabel, appVersion } from "./version.js";
63
66
  import { checkForUpdate } from "./updateCheck.js";
64
- import { ANALYTICS_EXPLANATION, analyticsEnabled, sendAnalyticsPing, setAnalyticsEnabled, startupStatusLine, } from "./analytics.js";
67
+ import { ANALYTICS_EXPLANATION, sendAnalyticsPing, } from "./analytics.js";
68
+ import { APP_EXPLANATION, APP_PAGE_URL, appAnnouncement, openInBrowser } from "./desktopApp.js";
65
69
  import { PromptInput } from "./components/PromptInput.js";
66
70
  import { Picker } from "./components/Picker.js";
67
71
  import { ApprovalBox } from "./components/ApprovalBox.js";
@@ -73,9 +77,14 @@ import { applyScreenMode } from "./screenShell.js";
73
77
  import { saveScreenMode } from "./screenStore.js";
74
78
  import { needsMeasure, pruneHeights } from "./blockHeights.js";
75
79
  import { BASE_COMMANDS } from "./commands.js";
80
+ import { dismissMarathon } from "../core/turnRunner.js";
81
+ import { describeMarathonEvent, resumeMarathon, startMarathon } from "../dynamo/marathon.js";
82
+ import { MarathonBox } from "./components/MarathonBox.js";
83
+ import { isFinished, isLive, marathonUiReduce } from "./marathonUi.js";
76
84
  import { manualCommand, refusalReason } from "./selfUpdate.js";
77
85
  import { currentInstall, requestRestart, runUpdate } from "./updateRunner.js";
78
86
  import { enableMouse, readMouse, readWheel } from "./mouse.js";
87
+ import { wheelLines, wheelStart } from "./wheelAccel.js";
79
88
  import { applySelection, ctrlCShouldCopy, isEmpty, selectionText } from "./selection.js";
80
89
  import { latestScreen, repaintOverlay, setFrameOverlay } from "./framebuffer/overlay.js";
81
90
  import { requestFullRepaint } from "./framebuffer/writer.js";
@@ -85,36 +94,42 @@ import { growFill, INLINE_LIVE_RESERVE, NO_FILL } from "./startupFill.js";
85
94
  import { setRowsBelowCaret } from "./exitCursor.js";
86
95
  import { caretCell } from "./caretPark.js";
87
96
  import { countNewReplies, hitsPill, pillBounds, scrollPill } from "./scrollPill.js";
88
- import { virtualWindow } from "./virtualWindow.js";
97
+ import { SCROLLBACK_BLOCKS, virtualWindow } from "./virtualWindow.js";
89
98
  import { perf, perfEnabled } from "./perfLog.js";
90
- import { isGroupMember, groupSettled, planGroupReveal, planStandaloneReveal, resultQueued, STANDALONE_HOLD_MS } from "./groupReveal.js";
99
+ import { newPacer, nextHoldAt, nextMove, takeImmediate, takePaced } from "./revealQueue.js";
91
100
  import { drain as drainQueue, popAll as popAllQueued, queueMessage, takeSteerable, visibleQueue } from "./messageQueue.js";
92
101
  import { routeCommand, parseCommandLine, unknownCommandMessage } from "./commandRoute.js";
93
102
  import { resolveChoice, splitModelArg } from "./commandArgs.js";
94
103
  import { carryAcrossFreshSession } from "./sessionCarry.js";
95
- import { toolDisplay, isGroupable, KIND_COLOR } from "./toolDisplay.js";
104
+ import { fitBanner } from "./bannerFit.js";
105
+ import { neverShown, toolDisplay, isGroupable, narrationShown, noteReads } from "./toolDisplay.js";
96
106
  import { workingVerb } from "./workingVerb.js";
107
+ import { PulseDot } from "./components/PulseDot.js";
97
108
  import { narrationPending, revealWait } from "./revealPace.js";
98
109
  import { summarizeTask, formatTokens } from "../dynamo/pricing.js";
99
- import { meterReset, meterDelta, meterTick, meterValue } from "../dynamo/liveMeter.js";
110
+ import { meterReset, meterDelta, meterUsage, meterTick, meterValue } from "../dynamo/liveMeter.js";
100
111
  import { addServerToConfig, configPathFor, parseAddSpec, removeServerFromConfig, resolveConfigPath, splitArgs } from "../mcp/configWrite.js";
101
112
  import { mapPromptArguments, promptCommand, promptUsage } from "../mcp/prompts.js";
102
113
  import { DEFAULT_MODE, modeById, modeFromFlags, nextMode } from "./modes.js";
103
114
  import { ApprovalChannel } from "./approvalChannel.js";
115
+ import { ACCENT, WARN } from "./theme.js";
104
116
  const MINDWEAVE_DOCS_URL = "https://mindweave.dev";
105
117
  /** How long each hint under the input box stays up. Long enough to read twice without
106
118
  * hurrying, short enough that a session sees the whole set rather than one of them. */
107
119
  const TIP_ROTATE_MS = 12_000;
120
+ /** Two Esc presses this close together, on an empty box, open /rewind. */
121
+ const DOUBLE_ESC_MS = 600;
108
122
  /** Commands whose whole job is to open a surface in the box under the input. Written out
109
123
  * in full, because only a bare invocation opens anything: given an argument each of these
110
124
  * acts directly and there is no surface to hold the frame for. */
111
125
  const OVERLAY_COMMANDS = new Set([
112
- "/analytics",
126
+ "/app",
113
127
  "/continue",
114
128
  "/key",
115
129
  "/mcp",
116
130
  "/model",
117
131
  "/provider",
132
+ "/rewind",
118
133
  "/shells",
119
134
  "/think",
120
135
  ]);
@@ -318,6 +333,11 @@ export function App({ resumeSessionId, initialScreen }) {
318
333
  // PromptInput's own state, so App is not re-rendered by it and would otherwise
319
334
  // keep sizing the chat against a footer that no longer exists — leaving the
320
335
  // menu clipped off the bottom of the frame with nothing to correct it.
336
+ // The Marathon panel: null when there is no run and none is armed. It sits in the
337
+ // measured footer, so it pushes the chat up instead of overlapping it.
338
+ const [marathonUi, setMarathonUi] = useState(null);
339
+ const marathonUiRef = useRef(null);
340
+ marathonUiRef.current = marathonUi;
321
341
  const [, bumpFooter] = useState(0);
322
342
  const onMenuChange = useCallback(() => bumpFooter((t) => t + 1), []);
323
343
  // Turn timing for the status line: when the current turn started, how long the
@@ -341,8 +361,14 @@ export function App({ resumeSessionId, initialScreen }) {
341
361
  // reads the current value there.
342
362
  const meter = useRef(meterReset());
343
363
  const liveTokens = useCallback(() => meterValue(meter.current), []);
364
+ const lastMeterTick = useRef(0);
344
365
  const advanceTokens = useCallback(() => {
345
- meter.current = meterTick(meter.current);
366
+ // By the time actually elapsed since the last tick, not by tick count: the clock runs at the
367
+ // display's pace and a late tick must move the figure further, not the same distance.
368
+ const now = performance.now();
369
+ const dt = lastMeterTick.current ? now - lastMeterTick.current : LIVE_TICK_MS;
370
+ lastMeterTick.current = now;
371
+ meter.current = meterTick(meter.current, dt);
346
372
  }, []);
347
373
  // Aborts the in-flight turn when the user presses Esc (created fresh per turn).
348
374
  const abortRef = useRef(null);
@@ -488,7 +514,7 @@ export function App({ resumeSessionId, initialScreen }) {
488
514
  // gone whatever we do; reprinting all of it costs about 1.7ms a block, measured, so
489
515
  // a long session spent a third of a second on a blank screen printing scrollback
490
516
  // nobody asked to see. A couple of screens is all that can be looked at anyway.
491
- reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
517
+ reprintFrom.current = Math.max(0, stateRef.current.committed.length - INLINE_REPRINT_BLOCKS);
492
518
  // Blank rows so the conversation lands at the BOTTOM of the screen rather than the
493
519
  // top. A terminal prints from wherever the cursor is, which after leaving the
494
520
  // alternate screen is wherever the shell left it — usually near the top, with the
@@ -523,6 +549,8 @@ export function App({ resumeSessionId, initialScreen }) {
523
549
  // holds them stays on screen for the gap between the command list closing and the
524
550
  // surface appearing. See `OVERLAY_COMMANDS`.
525
551
  const [opening, setOpening] = useState(false);
552
+ /** A message handed back to the input box to edit (a rewind). A new object each time. */
553
+ const [fill, setFill] = useState(undefined);
526
554
  // The approval channel handed to tools: a forbidden-path tool calls this to ask
527
555
  // the user Yes/No/other, and we render it as an overlay that resolves the promise.
528
556
  // A ref so the function injected into a session's tool context always reaches the
@@ -584,9 +612,14 @@ export function App({ resumeSessionId, initialScreen }) {
584
612
  // it WAS a diff did not, so every resumed edit rendered as dim plain lines. Anything
585
613
  // added to a row's appearance has to be stored here too, or the claim above quietly
586
614
  // stops being true again.
615
+ // What compaction took out is drawn too (memory/earlier.ts), so a resumed chat is the whole conversation.
616
+ const withEarlier = (session) => [...(session.earlier ?? []), ...session.transcript];
587
617
  function showResumed(transcript) {
618
+ const readThisTurn = new Set(); // for narrationShown, as live
588
619
  for (const e of transcript) {
589
620
  if (e.role === "user") {
621
+ if (!e.synthetic)
622
+ readThisTurn.clear();
590
623
  // Engine nudges ride as `user` messages so the model reads them as instruction,
591
624
  // but they are ours, not the person's. Replaying one draws it as a `>` prompt
592
625
  // the user never typed — seen live as "> That was 3 sentences between tool
@@ -602,15 +635,18 @@ export function App({ resumeSessionId, initialScreen }) {
602
635
  // re-announce each tool call exactly as streamRespond does.
603
636
  // Same cut as the live path: prose that precedes tool calls is narration,
604
637
  // and a resumed chat must not print the essays the live one trimmed away.
605
- if (e.content.trim()) {
606
- const intermediate = (e.toolCalls?.length ?? 0) > 0;
638
+ const calls = (e.toolCalls ?? []).map((c) => ({ name: c.name, args: parseToolArgs(c.arguments) }));
639
+ const shown = calls.length === 0 || narrationShown(calls, readThisTurn);
640
+ noteReads(calls, readThisTurn);
641
+ if (shown && e.content.trim()) {
642
+ const intermediate = calls.length > 0;
607
643
  dispatch({ type: "say", text: intermediate ? trimNarration(e.content) : e.content });
608
644
  }
609
645
  for (const call of e.toolCalls ?? []) {
610
646
  // The spawn itself is drawn by its sub-agent block live, never as a raw row.
611
647
  // Replaying it as one puts a `● SpawnSubagent(...)` in a resumed transcript
612
648
  // that was not in the live one.
613
- if (call.name === "spawn_subagent")
649
+ if (call.name === "spawn_subagent" || neverShown(call.name))
614
650
  continue;
615
651
  const d = toolDisplay(call.name, parseToolArgs(call.arguments));
616
652
  dispatch({
@@ -797,7 +833,7 @@ export function App({ resumeSessionId, initialScreen }) {
797
833
  widthBefore.current = width;
798
834
  if (shell !== "inline")
799
835
  return;
800
- reprintFrom.current = Math.max(0, committed.length - INLINE_REPRINT_BLOCKS);
836
+ reprintFrom.current = Math.max(0, stateRef.current.committed.length - INLINE_REPRINT_BLOCKS);
801
837
  startFill.current = Math.max(0, rows - INLINE_LIVE_RESERVE);
802
838
  setStaticEpoch((n) => n + 1);
803
839
  }, [width, shell]);
@@ -817,7 +853,7 @@ export function App({ resumeSessionId, initialScreen }) {
817
853
  session.current = s;
818
854
  setReady(true);
819
855
  if (resumeSessionId && s.id === resumeSessionId) {
820
- showResumed(s.transcript);
856
+ showResumed(withEarlier(s));
821
857
  note(`— updated to v${appVersion()}, continuing —`);
822
858
  }
823
859
  // The project may have a model saved from a different provider than the
@@ -826,13 +862,22 @@ export function App({ resumeSessionId, initialScreen }) {
826
862
  if (!cur)
827
863
  return;
828
864
  const need = missingKeyFor(cur.modelConfig.model);
829
- if (!need)
865
+ if (!need) {
866
+ // The model the project saved can run. Setup may still be open: it was decided before a local
867
+ // runtime (Ollama) had been asked whether it runs, and with the saved model already usable the
868
+ // branch below that closes it never ran, so every launch after the first showed "add a key"
869
+ // to anyone whose only provider is local.
870
+ setSetupOpen(false);
830
871
  return;
872
+ }
831
873
  // A saved config can outlive the key that made it usable. Rather than open
832
874
  // straight into an inescapable prompt, fall back to a provider we can actually
833
875
  // run and say so — the user can always pick again with /provider.
834
876
  const fallback = usableFallback(cur.modelConfig.model, hasApiKey);
835
877
  if (fallback) {
878
+ // Setup may already be open: it is decided before a local runtime (Ollama) has been
879
+ // asked whether it runs. Something runnable was found, so there is nothing to set up.
880
+ setSetupOpen(false);
836
881
  void switchTo(fallback, providerOf(fallback).label).then(() => note(`no ${need.label} key yet — using ${providerOf(fallback).label} instead. /provider to change.`));
837
882
  return;
838
883
  }
@@ -841,10 +886,10 @@ export function App({ resumeSessionId, initialScreen }) {
841
886
  setSetupOpen(true);
842
887
  });
843
888
  }, []);
844
- // Anonymous usage ping — on by default. The status line shows every launch, not just
845
- // the first one; the full explanation lives in the /analytics box itself.
889
+ // The usage ping is paused for now (see analytics.ts). What every session starts with instead is
890
+ // the pointer to the desktop app, shown every launch.
846
891
  useEffect(() => {
847
- note(startupStatusLine());
892
+ note(appAnnouncement());
848
893
  sendAnalyticsPing(appVersion());
849
894
  }, []);
850
895
  // A quiet, backgrounded check for a newer release — independent of session startup so a
@@ -896,12 +941,30 @@ export function App({ resumeSessionId, initialScreen }) {
896
941
  // and a keypress that both stopped the turn and silently emptied the queue would be
897
942
  // two decisions on one key. So mid-turn Esc is declined here and the interrupt
898
943
  // handler above has it alone. ↑ has no such conflict and always pops.
944
+ // Esc twice on an empty box while nothing runs opens /rewind. The first press is
945
+ // remembered here because this is the one place an idle Esc arrives with the box's text.
946
+ const lastIdleEsc = useRef(0);
947
+ const openRewind = useRef(() => { });
948
+ openRewind.current = () => {
949
+ setOpening(true);
950
+ void handleCommand("/rewind").finally(() => setOpening(false));
951
+ };
899
952
  const popQueue = useCallback((input, cursor, via) => {
900
953
  if (via === "escape" && busy)
901
954
  return undefined;
902
955
  const popped = popAllQueued(queueRef.current, input, cursor);
903
- if (!popped)
956
+ if (!popped) {
957
+ if (via === "escape" && input.trim() === "") {
958
+ const now = Date.now();
959
+ if (now - lastIdleEsc.current < DOUBLE_ESC_MS) {
960
+ lastIdleEsc.current = 0;
961
+ openRewind.current();
962
+ }
963
+ else
964
+ lastIdleEsc.current = now;
965
+ }
904
966
  return undefined;
967
+ }
905
968
  queueRef.current = [];
906
969
  setQueued([]);
907
970
  return popped;
@@ -970,6 +1033,9 @@ export function App({ resumeSessionId, initialScreen }) {
970
1033
  const mgr = session.current?.toolContext.backgroundShells;
971
1034
  for (const sh of mgr?.running() ?? [])
972
1035
  mgr?.kill(sh.id, "user");
1036
+ // And the app it was testing, with its live view and any hidden browser.
1037
+ if (session.current)
1038
+ void stopUi(session.current.toolContext);
973
1039
  flush.current = true; // drain the rest of the queue immediately
974
1040
  // A held-but-unsettled group (see pump()) waits for the NEXT enqueued
975
1041
  // action to notice anything changed — nothing schedules a timer while
@@ -983,6 +1049,12 @@ export function App({ resumeSessionId, initialScreen }) {
983
1049
  // more line of the app talking about itself.
984
1050
  }
985
1051
  }, { isActive: busy && overlay === null });
1052
+ // Esc on an armed Marathon box takes it down again (nothing has started). Once a run is
1053
+ // going, Esc is the ordinary stop above and the run becomes resumable instead.
1054
+ useInput((_input, key) => {
1055
+ if (key.escape)
1056
+ setMarathonUi(null);
1057
+ }, { isActive: !busy && overlay === null && marathonUi?.phase === "armed" });
986
1058
  // shift-tab cycles the interaction mode (Lightning ⇄ Architect). Active whenever
987
1059
  // the input owns the keyboard (no overlay); takes effect from the next turn.
988
1060
  useInput((_input, key) => {
@@ -1014,6 +1086,8 @@ export function App({ resumeSessionId, initialScreen }) {
1014
1086
  // counter kept climbing — and every one of those phantom lines then had to be
1015
1087
  // scrolled back down before the view moved at all. A flick or two past the top bought
1016
1088
  // a second of a wheel that did nothing, which reads as the app having frozen.
1089
+ /** The wheel gesture in progress, for scroll acceleration (wheelAccel.ts). */
1090
+ const wheelRef = useRef(wheelStart());
1017
1091
  const scrollBy = useCallback((lines) => {
1018
1092
  // Repaint the whole next frame. A scroll can move a row out from under the
1019
1093
  // framebuffer's model (see requestFullRepaint), which is what left a transcript row
@@ -1338,7 +1412,12 @@ export function App({ resumeSessionId, initialScreen }) {
1338
1412
  // Content moves out from under a selection when the view scrolls, so the
1339
1413
  // highlight would be sitting on text that is no longer the text it copied.
1340
1414
  clearSelection();
1341
- const lines = notches.reduce((n, dir) => n + (dir === "up" ? WHEEL_LINES : -WHEEL_LINES), 0);
1415
+ // Signed notch count, then the distance for it: the faster the wheel is turning,
1416
+ // the further each notch goes (wheelAccel.ts). A slow notch is still three lines.
1417
+ const net = notches.reduce((n, dir) => n + (dir === "up" ? 1 : -1), 0);
1418
+ const step = wheelLines(wheelRef.current, net, performance.now());
1419
+ wheelRef.current = step.state;
1420
+ const lines = step.lines;
1342
1421
  // The wheel is how anyone actually scrolls, so in the inline shell it is what
1343
1422
  // opens the reading view. Turning it UP is the gesture: the reader is going back
1344
1423
  // through the conversation and wants the prompt to stay where they can type into
@@ -1422,6 +1501,7 @@ export function App({ resumeSessionId, initialScreen }) {
1422
1501
  clearSelection();
1423
1502
  }, { isActive: true });
1424
1503
  function endTurn() {
1504
+ releaseLiveGates();
1425
1505
  if (turnStart.current != null)
1426
1506
  setLastMs(Date.now() - turnStart.current);
1427
1507
  // Settle every tool row this turn produced into its past-tense verb. The single
@@ -1626,52 +1706,39 @@ export function App({ resumeSessionId, initialScreen }) {
1626
1706
  const pumpTimer = useRef(null);
1627
1707
  const streamDone = useRef(false);
1628
1708
  const flush = useRef(false); // Esc → drain the rest with no pacing
1629
- // Whether a "tools" group is currently visible (already revealed and still
1630
- // open). Tracked at this layer — not read from transcript state — because the
1631
- // decision it drives (does the NEXT grouped toolStart need to be held) has to
1632
- // be made before that action is even dispatched.
1633
- const groupOpen = useRef(false);
1634
- // When the row currently at the front of the queue began waiting for its own result,
1635
- // and the timer that gives up on it. See the hold in pump().
1636
- const heldStart = useRef(null);
1709
+ // What is waiting, which rows on screen are still working, and whether a read row is open.
1710
+ // The rules live in revealQueue.ts, which the tests replay; this only drives them.
1711
+ const pacer = useRef(newPacer(revealQ.current));
1712
+ // Commands wait for their own row (RespondOptions.beforeLiveTool): call id -> the engine's
1713
+ // "go". Released when the row reaches the screen, and ALWAYS released on Esc, at the end of
1714
+ // the queue and at the end of a turn, because a gate nobody opens is a turn that never ends.
1715
+ const liveGates = useRef(new Map());
1716
+ // Every tool row that has reached the screen this turn, so a gate asked for after its row
1717
+ // is already up opens at once.
1718
+ const shownRows = useRef(new Set());
1637
1719
  const holdTimer = useRef(null);
1638
- // A new block appears (paced); a token (silent), a tool resolution (in place), a
1639
- // discovery call folding into an ALREADY-OPEN group, or a sub-agent's nested
1640
- // activity (folds into / resolves its rail in place) is not.
1641
- //
1642
- // Both kinds of tool start are paced AND held: pump() waits for the matching
1643
- // toolEnd so the row arrives complete, then reveals the pair on the beat. (It used
1644
- // to show instantly as a bare header and then patch in place; that was the
1645
- // two-stage reveal the hold mechanism exists to remove.) Holding and pacing are
1646
- // separate questions — one is about the block being whole, the other about when a
1647
- // whole block is allowed on screen — and a standalone row used to answer only the
1648
- // first, which is why a burst of edits landed together however calm the rest of
1649
- // the turn was.
1650
- const isPaced = (a) => {
1651
- if (a.type === "toolStart")
1652
- return a.group ? !groupOpen.current : true;
1653
- return (a.type !== "token" &&
1654
- a.type !== "toolEnd" &&
1655
- a.type !== "toolProgress" &&
1656
- a.type !== "subToolStart" &&
1657
- a.type !== "subToolEnd" &&
1658
- a.type !== "subagentEnd");
1659
- };
1720
+ function releaseLiveGates() {
1721
+ for (const go of liveGates.current.values())
1722
+ go();
1723
+ liveGates.current.clear();
1724
+ }
1725
+ /** Start a fresh pacer over an empty queue (a new turn, a cleared conversation). */
1726
+ function resetPacer() {
1727
+ releaseLiveGates();
1728
+ shownRows.current.clear();
1729
+ if (holdTimer.current)
1730
+ clearTimeout(holdTimer.current);
1731
+ holdTimer.current = null;
1732
+ revealQ.current = [];
1733
+ pacer.current = newPacer(revealQ.current);
1734
+ }
1660
1735
  function enqueueReveal(a) {
1661
1736
  revealQ.current.push(a);
1662
- // A result arriving is exactly what the hold below is waiting for, so its deadline is
1663
- // cancelled rather than waited out — otherwise every quick tool would sit out the
1664
- // full grace before its pair could be shown.
1665
- if (holdTimer.current) {
1666
- clearTimeout(holdTimer.current);
1667
- holdTimer.current = null;
1668
- }
1669
1737
  if (!pumpTimer.current)
1670
1738
  pump();
1671
1739
  }
1672
- // Reveal the next block on the beat (a minimum since the last reveal, not an added
1673
- // delay), then stamp the clock and carry on draining. Every paced path in pump()
1674
- // goes through here, so the tempo is decided in exactly one place.
1740
+ // Reveal the next block on the beat, then stamp the clock and carry on draining. Every
1741
+ // paced path in pump() goes through here, so the tempo is decided in exactly one place.
1675
1742
  function schedulePaced(reveal) {
1676
1743
  const wait = revealWait({ flush: flush.current });
1677
1744
  pumpTimer.current = setTimeout(() => {
@@ -1686,150 +1753,78 @@ export function App({ resumeSessionId, initialScreen }) {
1686
1753
  pump();
1687
1754
  }, wait);
1688
1755
  }
1689
- function pump() {
1690
- // Apply immediate actions at once: silent tokens, in-place resolves, and a
1691
- // discovery call folding into a group that's already on screen.
1692
- while (revealQ.current.length > 0 && !isPaced(revealQ.current[0])) {
1693
- const a = revealQ.current.shift();
1694
- if (a.type === "token")
1695
- applySilent(a);
1696
- else {
1697
- // An in-place resolve grows a row into its result, shifting everything below it —
1698
- // the same de-sync risk a reveal carries, so heal the banner on the same frame.
1699
- requestFullRepaint();
1700
- dispatch(a);
1701
- }
1702
- // A group stays open once shown; anything that isn't part of it (a
1703
- // standalone tool, narration, a sub-agent) closes it, mirroring exactly
1704
- // what the transcript reducer's own closeToolGroup does on the same actions.
1705
- if (a.type === "toolStart" && a.group)
1706
- groupOpen.current = true;
1707
- else if (a.type !== "toolEnd")
1708
- groupOpen.current = false;
1709
- }
1710
- if (revealQ.current.length === 0) {
1711
- if (streamDone.current) {
1712
- streamDone.current = false;
1713
- endTurn();
1714
- }
1756
+ /** Apply a run of actions as one paint; streamed text alone is applied without one. */
1757
+ function applyNow(actions) {
1758
+ if (actions.length === 0)
1715
1759
  return;
1716
- }
1717
- const front = revealQ.current[0];
1718
- // Narration waiting in front of a tool call gets the beat to itself. `toolStart`
1719
- // seals the open assistant block as part of its own action, so without this the
1720
- // sentence and the row it introduces reach the terminal in the SAME paint and
1721
- // land as one clump — the pacer's blind spot, since nothing was ever queued for
1722
- // the text. Sealing it first lets the sentence be read before the row appears
1723
- // under it. Only when a block will actually result (see narrationPending): the
1724
- // narration budget is one line per turn, and pausing for a sentence that seals
1725
- // to nothing would be an empty beat, which is a stall rather than a rhythm.
1726
- if (front.type === "toolStart" && narrationPending(stateRef.current)) {
1727
- schedulePaced(() => {
1728
- dispatch({ type: "sealNarration" });
1729
- groupOpen.current = false;
1730
- });
1760
+ if (actions.every((a) => a.type === "token")) {
1761
+ for (const a of actions)
1762
+ applySilent(a);
1731
1763
  return;
1732
1764
  }
1733
- // A tool's opening call is HELD — not dispatched, not scheduled, nothing shown —
1734
- // until its result is queued behind it, so the row never appears bare and then
1735
- // sprouts a body a second later. Holding costs nothing that was worth having:
1736
- // the header alone names a call whose result is the entire point of showing it,
1737
- // and the footer's live timer is what says work is happening. There is no
1738
- // time-based fallback (see groupReveal.ts): every later enqueueReveal re-enters
1739
- // pump, which re-checks. Esc sets `flush`, and `streamDone` releases the hold
1740
- // unconditionally — once the stream is over no further event can arrive, so a
1741
- // call whose end never came (an abort mid-flight) must still be shown rather
1742
- // than stranding the queue and the turn with it.
1743
- //
1744
- // Then the whole held burst reveals in ONE paint, on the beat. Painting per
1745
- // action would show the block assembling itself (header, then a running row,
1746
- // then the resolved row): Ink's root is a legacy React root, so every dispatch
1747
- // flushes synchronously and each one is a frame the terminal actually shows.
1748
- if (front.type === "toolStart") {
1749
- const isNewGroup = front.group && !groupOpen.current;
1750
- if (isNewGroup) {
1751
- if (planGroupReveal(groupSettled(revealQ.current.slice(1)), flush.current) === "hold")
1752
- return;
1765
+ // A command's row going on screen is its "go": it starts now, so its timer starts now, and
1766
+ // it stays visibly running for at least a moment even if it ends in a blink.
1767
+ const now = Date.now();
1768
+ for (const a of actions) {
1769
+ if (a.type !== "toolStart")
1770
+ continue;
1771
+ shownRows.current.add(a.toolId);
1772
+ const go = liveGates.current.get(a.toolId);
1773
+ if (go) {
1774
+ liveGates.current.delete(a.toolId);
1775
+ a.at = now;
1776
+ go();
1753
1777
  }
1754
- else {
1755
- // A standalone row is held for its own result, but only for so long.
1756
- //
1757
- // Held with no limit, a row was invisible for the ten
1758
- // minutes the build took: the last thing on screen stayed the tool before it, and
1759
- // an agent working steadily was indistinguishable from one that had hung. It was
1760
- // reported as a hang. It was not one — the command ran, the timeout fired, the
1761
- // shell was backgrounded, all of it correct and none of it visible.
1762
- if (heldStart.current?.toolId !== front.toolId) {
1763
- heldStart.current = { toolId: front.toolId, at: Date.now() };
1764
- }
1765
- const heldForMs = Date.now() - heldStart.current.at;
1766
- const plan = planStandaloneReveal({
1767
- resultQueued: resultQueued(front.toolId, revealQ.current),
1768
- flushing: flush.current,
1769
- streamDone: streamDone.current,
1770
- heldForMs,
1771
- });
1772
- if (plan === "hold") {
1773
- // Re-enter when the deadline passes. Its OWN timer, not the pacing one: the
1774
- // result arriving must be able to cancel this and reveal the pair at once, and
1775
- // clearing the pacing timer instead would drop the beat.
1776
- if (!holdTimer.current) {
1777
- holdTimer.current = setTimeout(() => {
1778
- holdTimer.current = null;
1779
- pump();
1780
- }, Math.max(0, STANDALONE_HOLD_MS - heldForMs));
1781
- }
1782
- return;
1783
- }
1778
+ }
1779
+ // A row resolving in place grows into its result and shifts everything below it — the
1780
+ // same de-sync risk a reveal carries, so the banner is healed on the same frame.
1781
+ requestFullRepaint();
1782
+ applyBatch(actions);
1783
+ }
1784
+ function pace() {
1785
+ return {
1786
+ flushing: flush.current,
1787
+ streamDone: streamDone.current,
1788
+ narrationPending: narrationPending(stateRef.current),
1789
+ now: Date.now(),
1790
+ };
1791
+ }
1792
+ function pump() {
1793
+ // A beat already scheduled keeps its time, unless Esc asked for everything now.
1794
+ if (pumpTimer.current) {
1795
+ if (!flush.current)
1796
+ return;
1797
+ clearTimeout(pumpTimer.current);
1798
+ pumpTimer.current = null;
1799
+ }
1800
+ if (flush.current)
1801
+ releaseLiveGates();
1802
+ applyNow(takeImmediate(pacer.current, pace()));
1803
+ const move = nextMove(pacer.current);
1804
+ // A row on screen is still working: its result, when it comes, re-enters here through
1805
+ // enqueueReveal. The beat for the next block starts only after that. A result that is
1806
+ // already here but held (a command shorter than its minimum running time) needs a timer,
1807
+ // because nothing else will arrive to wake the pump.
1808
+ if (move === "wait") {
1809
+ const at = nextHoldAt(pacer.current);
1810
+ if (at !== null && !holdTimer.current) {
1811
+ holdTimer.current = setTimeout(() => {
1812
+ holdTimer.current = null;
1813
+ pump();
1814
+ }, Math.max(0, at - Date.now()));
1784
1815
  }
1785
- schedulePaced(() => {
1786
- // Measured HERE, not when the beat was scheduled: the queue keeps growing
1787
- // while we wait, and a group's burst can gain members in that window. A span
1788
- // measured early would leave the stragglers behind to open a second group,
1789
- // splitting one burst across two blocks.
1790
- if (isNewGroup) {
1791
- // A group folds into ONE row ("Read 8 files"), so its whole burst is a single
1792
- // block and reveals together by design.
1793
- let take = 0;
1794
- while (take < revealQ.current.length && isGroupMember(revealQ.current[take]))
1795
- take++;
1796
- applyBatch(revealQ.current.splice(0, take));
1797
- }
1798
- else {
1799
- // This call and its OWN result — never the span between them. The engine emits
1800
- // every toolStart of a batch before running any of it, so when those calls run
1801
- // concurrently the matching end sits behind the other calls' starts. Taking a
1802
- // contiguous span from the front swallowed all of them into this one paint:
1803
- // eight rows appearing at once, however calm the beat before it was. Each of
1804
- // those calls is its own block and waits its own beat; only the pair is atomic,
1805
- // so a row still arrives carrying its result rather than sprouting one later.
1806
- const endIdx = revealQ.current.findIndex((x) => x.type === "toolEnd" && x.toolId === front.toolId);
1807
- if (endIdx === -1) {
1808
- applyBatch(revealQ.current.splice(0, 1));
1809
- }
1810
- else {
1811
- // The end first: it sits at the higher index, so removing it cannot shift
1812
- // the start out from under the shift() that follows.
1813
- const endAction = revealQ.current.splice(endIdx, 1)[0];
1814
- const startAction = revealQ.current.shift();
1815
- applyBatch([startAction, endAction]);
1816
- }
1817
- }
1818
- // Resolved either way — the action that closes the block follows next.
1819
- groupOpen.current = false;
1820
- });
1821
1816
  return;
1822
1817
  }
1823
- // Every remaining paced block (a sealed reply, a sub-agent start, notes) reveals
1824
- // on the same beat.
1825
- schedulePaced(() => {
1826
- const a = revealQ.current.shift();
1827
- if (a) {
1828
- dispatch(a);
1829
- if (a.type !== "toolEnd")
1830
- groupOpen.current = a.type === "toolStart" && !!a.group;
1818
+ if (move === "idle") {
1819
+ // Nothing is queued, so a command still waiting for its row has no row coming: let it run.
1820
+ releaseLiveGates();
1821
+ if (streamDone.current) {
1822
+ streamDone.current = false;
1823
+ endTurn();
1831
1824
  }
1832
- });
1825
+ return;
1826
+ }
1827
+ schedulePaced(() => applyNow(takePaced(pacer.current, pace())));
1833
1828
  }
1834
1829
  /**
1835
1830
  * Run one streaming turn against the session. Engine events are QUEUED onto the
@@ -1891,26 +1886,19 @@ export function App({ resumeSessionId, initialScreen }) {
1891
1886
  }
1892
1887
  return out;
1893
1888
  }
1894
- async function streamRespond(s) {
1889
+ async function streamRespond(s, run) {
1895
1890
  startTurn();
1896
1891
  // Pick up an edit the model made to MINDWEAVE.md, but only if one actually happened
1897
1892
  // — this is a no-op otherwise. Re-reading unconditionally used to look free and was
1898
1893
  // not: it rewrites the system prompt string, which discards the entire cached prefix
1899
1894
  // (base prompt, tool schemas, project snapshot) at 1.25x rewrite cost.
1900
1895
  await reloadProjectMemory(s).catch(() => { });
1901
- revealQ.current = [];
1896
+ resetPacer();
1902
1897
  lastRevealAt.current = 0;
1903
1898
  streamDone.current = false;
1904
1899
  flush.current = false;
1905
- // A hold belongs to one turn. Left standing, its deadline fires into the next one and
1906
- // pumps a queue that has nothing to do with it.
1907
- heldStart.current = null;
1908
- if (holdTimer.current) {
1909
- clearTimeout(holdTimer.current);
1910
- holdTimer.current = null;
1911
- }
1912
1900
  try {
1913
- await respond(s, {
1901
+ const turnOptions = {
1914
1902
  // Messages typed while this turn runs reach it here, at each step boundary,
1915
1903
  // rather than waiting for it to end and starting another one.
1916
1904
  steer: () => steerRunningTurn(s),
@@ -1931,9 +1919,15 @@ export function App({ resumeSessionId, initialScreen }) {
1931
1919
  else if (e.type === "replyReset") {
1932
1920
  enqueueReveal({ type: "resetReply" });
1933
1921
  }
1922
+ else if (e.type === "narration") {
1923
+ // Words that led only to unseen tools (searches, re-reads) are not shown at all.
1924
+ if (!e.shown)
1925
+ enqueueReveal({ type: "resetReply" });
1926
+ }
1934
1927
  else if (e.type === "tool" && e.phase === "start") {
1935
- // The spawn call itself is rendered by its sub-agent block, not a raw row.
1936
- if (e.name === "spawn_subagent")
1928
+ // The spawn call itself is rendered by its sub-agent block, not a raw row;
1929
+ // loading a tool is never shown at all (see mcpSearch.ts).
1930
+ if (e.name === "spawn_subagent" || neverShown(e.name))
1937
1931
  return;
1938
1932
  const d = toolDisplay(e.name, e.args);
1939
1933
  if (e.agent) {
@@ -1941,7 +1935,7 @@ export function App({ resumeSessionId, initialScreen }) {
1941
1935
  enqueueReveal({ type: "subToolStart", agentId: e.agent, toolId: e.id, name: d.name, arg: d.arg, action: d.kind });
1942
1936
  }
1943
1937
  else {
1944
- enqueueReveal({ type: "toolStart", toolId: e.id, name: d.name, arg: d.arg, meta: d.meta, action: d.kind, group: isGroupable(e.name), ...(d.covers ? { covers: d.covers } : {}) });
1938
+ enqueueReveal({ type: "toolStart", toolId: e.id, name: d.name, arg: d.arg, meta: d.meta, action: d.kind, group: isGroupable(e.name), at: Date.now(), ...(d.covers ? { covers: d.covers } : {}) });
1945
1939
  }
1946
1940
  }
1947
1941
  else if (e.type === "tool" && e.phase === "progress") {
@@ -1950,7 +1944,7 @@ export function App({ resumeSessionId, initialScreen }) {
1950
1944
  enqueueReveal({ type: "toolProgress", toolId: e.id, text: e.text });
1951
1945
  }
1952
1946
  else if (e.type === "tool" && e.phase === "end") {
1953
- if (e.name === "spawn_subagent")
1947
+ if (e.name === "spawn_subagent" || neverShown(e.name))
1954
1948
  return;
1955
1949
  if (e.agent) {
1956
1950
  enqueueReveal({ type: "subToolEnd", agentId: e.agent, toolId: e.id, ok: !e.error, summary: e.summary });
@@ -1986,7 +1980,13 @@ export function App({ resumeSessionId, initialScreen }) {
1986
1980
  else if (e.type === "subagent" && e.phase === "end") {
1987
1981
  enqueueReveal({ type: "subagentEnd", agentId: e.id, ok: !e.error, summary: e.summary });
1988
1982
  }
1983
+ else if (e.type === "todos") {
1984
+ setMarathonUi((ui) => marathonUiReduce(ui, { type: "todos", items: e.items }));
1985
+ }
1989
1986
  else if (e.type === "usage") {
1987
+ // The live figure swaps this call's streamed estimate for its real output size
1988
+ // (tool arguments never stream as text, so the estimate alone runs far behind).
1989
+ meter.current = meterUsage(meter.current, e.completionTokens);
1990
1990
  // Keep each call's real usage; fold into the context+cost summary shown
1991
1991
  // once the turn ends. The model id drives cache-aware pricing.
1992
1992
  usageSamples.current.push({
@@ -2004,11 +2004,26 @@ export function App({ resumeSessionId, initialScreen }) {
2004
2004
  }
2005
2005
  },
2006
2006
  signal: abortRef.current?.signal,
2007
+ // A command starts only once its row is on screen, so the row's running state is the
2008
+ // real work, one thing at a time, instead of a replay of something long finished.
2009
+ beforeLiveTool: (id) => new Promise((resolve) => {
2010
+ if (flush.current || abortRef.current?.signal.aborted || shownRows.current.has(id))
2011
+ return resolve();
2012
+ liveGates.current.set(id, resolve);
2013
+ if (!pumpTimer.current)
2014
+ pump();
2015
+ }),
2007
2016
  // Persist after every step so a hard crash / PC shutdown mid-turn loses at
2008
2017
  // most the current in-flight step — not the whole turn. (The finally below
2009
2018
  // still saves on clean/aborted exits.)
2010
2019
  persist: () => saveSession(s),
2011
- });
2020
+ };
2021
+ // A Marathon run drives the very same turns, so every event above draws identically
2022
+ // whether one turn or a hundred of them produced it.
2023
+ if (run)
2024
+ await run(turnOptions);
2025
+ else
2026
+ await respond(s, turnOptions);
2012
2027
  enqueueReveal({ type: "finishReply" });
2013
2028
  }
2014
2029
  catch (error) {
@@ -2035,6 +2050,34 @@ export function App({ resumeSessionId, initialScreen }) {
2035
2050
  pump(); // ensure we drain to endTurn even if idle now
2036
2051
  }
2037
2052
  }
2053
+ /**
2054
+ * Run a Marathon through this screen's own turn pipeline. It is the same `respond` loop
2055
+ * as any message, driven by `startMarathon`/`resumeMarathon`, so every tool row, token and
2056
+ * question draws exactly as it does in a normal turn. The box is fed by its events.
2057
+ */
2058
+ async function driveMarathon(s, go) {
2059
+ const onMarathon = (event) => {
2060
+ setMarathonUi((ui) => marathonUiReduce(ui, { type: "event", event }));
2061
+ // The outcome is worth one line in the conversation; the box carries the rest.
2062
+ if (event.type === "finished")
2063
+ enqueueReveal({ type: "note", text: describeMarathonEvent(event) });
2064
+ };
2065
+ try {
2066
+ await streamRespond(s, (options) => go({ ...options, onMarathon }));
2067
+ }
2068
+ finally {
2069
+ // However it ended (an error included), the box must not keep spinning for a run that is gone.
2070
+ setMarathonUi((ui) => (ui && isLive(ui) ? { ...ui, phase: "paused", line: "Stopped" } : ui));
2071
+ }
2072
+ }
2073
+ /** The armed box's next message: it becomes the goal. */
2074
+ async function beginMarathon(s, text) {
2075
+ const { content, displayText, notes, images } = await prepareMessage(s, text);
2076
+ dispatch({ type: "user", text: displayText });
2077
+ for (const n of notes)
2078
+ note(n);
2079
+ await driveMarathon(s, (options) => startMarathon(s, content, { ...options, ...(images.length > 0 ? { goalImages: images } : {}) }));
2080
+ }
2038
2081
  // When a background shell finishes while Mindweave is idle, kick a turn so the model
2039
2082
  // reports the result (and proposes a fix on failure). respond() drains the
2040
2083
  // completion event; the guard stops overlapping reactions.
@@ -2083,7 +2126,7 @@ export function App({ resumeSessionId, initialScreen }) {
2083
2126
  }
2084
2127
  }
2085
2128
  note(`— continuing${compactFirst ? " (compacted)" : ""}: ${sessionTitle(meta)} —`);
2086
- showResumed(resumed.transcript);
2129
+ showResumed(withEarlier(resumed));
2087
2130
  }
2088
2131
  /**
2089
2132
  * Drop the current chat and start a clean session in the same project.
@@ -2126,29 +2169,128 @@ export function App({ resumeSessionId, initialScreen }) {
2126
2169
  // The files the old conversation edited are still edited, and its checkpoints are
2127
2170
  // the only record of what they were before.
2128
2171
  carryAcrossFreshSession(s.toolContext, fresh.toolContext);
2172
+ // The history moves with it on disk too, so it still works after a restart and the
2173
+ // old session no longer claims turns the new one can undo.
2174
+ fresh.toolContext.checkpoints?.persistTo(checkpointDir(fresh.cwd, fresh.id));
2129
2175
  session.current = fresh;
2130
- if (wipeScreen) {
2131
- // The chips are keyed per conversation; a stale one would expand into a message
2132
- // the new session never saw.
2133
- pasteStore.current.clear();
2134
- setScrollUp(0);
2135
- // Anything the pacer is still holding belongs to the conversation being cleared.
2136
- // Left in place it would paint into the empty screen a moment later, which looks
2137
- // exactly like the clear having failed.
2138
- revealQ.current = [];
2139
- lastRevealAt.current = 0;
2140
- streamDone.current = false;
2141
- flush.current = false;
2142
- // The status line still reads "Cooked for 1m 23s" from a turn that is no longer
2143
- // on screen or in the model's context.
2144
- setTaskUsage(null);
2145
- setLastMs(null);
2146
- dispatch({ type: "clear" });
2147
- }
2176
+ if (wipeScreen)
2177
+ wipeChat();
2148
2178
  note(killed > 0
2149
2179
  ? `— started a fresh session (stopped ${killed} background command${killed === 1 ? "" : "s"}) —`
2150
2180
  : "— started a fresh session —");
2151
2181
  }
2182
+ /** Clear the chat off the screen, and everything that would paint into it afterwards. */
2183
+ function wipeChat() {
2184
+ // The chips are keyed per conversation; a stale one would expand into a message
2185
+ // the new session never saw.
2186
+ pasteStore.current.clear();
2187
+ setScrollUp(0);
2188
+ // Anything the pacer is still holding belongs to the conversation being cleared.
2189
+ // Left in place it would paint into the empty screen a moment later, which looks
2190
+ // exactly like the clear having failed.
2191
+ resetPacer();
2192
+ lastRevealAt.current = 0;
2193
+ streamDone.current = false;
2194
+ flush.current = false;
2195
+ // The status line still reads "Cooked for 1m 23s" from a turn that is no longer
2196
+ // on screen or in the model's context.
2197
+ setTaskUsage(null);
2198
+ setLastMs(null);
2199
+ dispatch({ type: "clear" });
2200
+ }
2201
+ /** "2 files +12 −3 · 1 rule": what the turns since this message changed. */
2202
+ function changeSummary(p) {
2203
+ const parts = [];
2204
+ if (p.files > 0)
2205
+ parts.push(`${p.files} file${p.files === 1 ? "" : "s"} +${p.added} −${p.removed}`);
2206
+ const state = stateSummary(p.state);
2207
+ if (state)
2208
+ parts.push(state);
2209
+ return parts.join(" · ");
2210
+ }
2211
+ function stateSummary(state) {
2212
+ const noun = {
2213
+ memory: ["memory", "memories"], rule: ["rule", "rules"], skill: ["skill", "skills"],
2214
+ permission: ["permission list", "permission lists"], mcp: ["MCP config", "MCP configs"],
2215
+ };
2216
+ return Object.entries(state)
2217
+ .filter(([, n]) => (n ?? 0) > 0)
2218
+ .map(([k, n]) => `${n} ${noun[k][n === 1 ? 0 : 1]}`)
2219
+ .join(", ");
2220
+ }
2221
+ /** The choices a rewind offers, each saying exactly what it does and what it cannot. */
2222
+ function rewindChoices(p) {
2223
+ const hasFiles = p.files > 0 || Object.keys(p.state).length > 0;
2224
+ const what = changeSummary(p);
2225
+ const caveat = [
2226
+ p.ranShell ? "Commands the agent ran are not undone." : "",
2227
+ !hasFiles && session.current?.toolContext.checkpoints?.wasResumed() ? "Changes from before this session was reopened stay." : "",
2228
+ ].filter(Boolean).join(" ");
2229
+ const choices = [];
2230
+ if (hasFiles) {
2231
+ choices.push({ label: "Conversation and files", description: `The chat goes back to before this message, which returns to the input box. Puts back ${what}. ${caveat}`.trim(), mode: "both" });
2232
+ }
2233
+ choices.push({ label: "Conversation only", description: `The chat goes back to before this message, which returns to the input box.${hasFiles ? " Files stay as they are now." : ""} ${caveat}`.trim(), mode: "conversation" });
2234
+ if (hasFiles) {
2235
+ choices.push({ label: "Files only", description: `Puts back ${what}. The conversation stays, and the agent is told. ${caveat}`.trim(), mode: "files" });
2236
+ }
2237
+ choices.push({ label: "Cancel", description: "Keep everything as it is.", mode: null });
2238
+ return choices;
2239
+ }
2240
+ async function applyRewind(point, mode) {
2241
+ const s = session.current;
2242
+ if (!s)
2243
+ return;
2244
+ const r = await rewindTo(s, point.at, mode);
2245
+ if (!r) {
2246
+ say("That message isn't in the conversation any more.");
2247
+ return;
2248
+ }
2249
+ // Files only: the conversation stays on screen as it is; only the report is new.
2250
+ if (mode !== "files") {
2251
+ wipeChat();
2252
+ showResumed(withEarlier(s));
2253
+ }
2254
+ reportRewind(s, r);
2255
+ if (mode !== "files")
2256
+ setFill({ text: refillText(r) });
2257
+ }
2258
+ function reportRewind(s, r) {
2259
+ const rel = (p) => relativize(s.toolContext, p);
2260
+ const list = (ps) => ps.map(rel).join(", ");
2261
+ const took = [];
2262
+ if (r.restored.length)
2263
+ took.push(`put back ${r.restored.length} file${r.restored.length === 1 ? "" : "s"}`);
2264
+ const state = {};
2265
+ for (const { kind } of r.restoredState)
2266
+ state[kind] = (state[kind] ?? 0) + 1;
2267
+ const stateLine = stateSummary(state);
2268
+ if (stateLine)
2269
+ took.push(`took back ${stateLine}`);
2270
+ const head = r.mode === "files" ? "— files rolled back to before your message" : "— rewound to before your message";
2271
+ note(`${head}${took.length ? `; ${took.join("; ")}` : ""} —`);
2272
+ if (r.conflicts.length)
2273
+ note(`left alone, changed since: ${list(r.conflicts)}`, { error: true });
2274
+ if (r.failed.length)
2275
+ note(`could not be put back: ${list(r.failed)}`, { error: true });
2276
+ if (r.skipped.length)
2277
+ note(`too large to have been kept, still changed: ${list(r.skipped)}`, { error: true });
2278
+ if (r.notRestored.length)
2279
+ note(`changed before this session was reopened, so still changed: ${list(r.notRestored)}`, { error: true });
2280
+ if (r.ranShell)
2281
+ note("commands the agent ran are not undone");
2282
+ }
2283
+ /** The message as the input box holds one: pastes as their chips, attachments as handles. */
2284
+ function refillText(r) {
2285
+ let text = r.message.template;
2286
+ r.message.pastes.forEach((paste, i) => {
2287
+ text = text.split(pasteSlot(i)).join(registerPaste(paste));
2288
+ });
2289
+ const attached = [...r.message.files, ...r.message.images].map((p) => `"${p}"`).join(" ");
2290
+ if (attached)
2291
+ text = `${text}${text ? " " : ""}${dropHandles.current.register(attached)}`;
2292
+ return text;
2293
+ }
2152
2294
  // Apply the resume-mode pick for a chosen session: compact & continue / as-is / fresh.
2153
2295
  async function applyResume(meta, mode) {
2154
2296
  if (mode === 2)
@@ -2199,6 +2341,11 @@ export function App({ resumeSessionId, initialScreen }) {
2199
2341
  note(`already on ${provider.label} · ${modelLabel(s.modelConfig.model)}`);
2200
2342
  return;
2201
2343
  }
2344
+ // A local runtime (Ollama) has no key to ask for: it has to be running, with a model pulled.
2345
+ if (provider.local && !hasApiKey(provider.apiKeyEnv)) {
2346
+ note(`${provider.label} isn't running here, or has no models yet. Start it and pull a model, then pick it again. ${provider.keysUrl}`);
2347
+ return;
2348
+ }
2202
2349
  const target = modelsOf(provider)[0];
2203
2350
  if (!target)
2204
2351
  return;
@@ -2272,10 +2419,19 @@ export function App({ resumeSessionId, initialScreen }) {
2272
2419
  setOverlay({ kind: "resumeMode", meta: o.items[index] });
2273
2420
  return;
2274
2421
  }
2422
+ if (o.kind === "rewind") {
2423
+ setOverlay({ kind: "rewindConfirm", point: o.items[index] });
2424
+ return;
2425
+ }
2275
2426
  setOverlay(null);
2276
- if (o.kind === "analytics") {
2277
- setAnalyticsEnabled(index === 0);
2278
- note(`Analytics turned ${index === 0 ? "on" : "off"}.`);
2427
+ if (o.kind === "rewindConfirm") {
2428
+ const mode = rewindChoices(o.point)[index]?.mode;
2429
+ if (mode)
2430
+ void applyRewind(o.point, mode);
2431
+ return;
2432
+ }
2433
+ if (o.kind === "app") {
2434
+ note(openInBrowser(APP_PAGE_URL) ? "Opened the download page in your browser." : `Could not open a browser. The page is ${APP_PAGE_URL}`);
2279
2435
  }
2280
2436
  else if (o.kind === "resumeMode")
2281
2437
  void applyResume(o.meta, index);
@@ -2421,8 +2577,7 @@ export function App({ resumeSessionId, initialScreen }) {
2421
2577
  exit();
2422
2578
  return;
2423
2579
  }
2424
- // /analytics — the anonymous usage ping. Bare opens the on/off switch, the same
2425
- // fixed box every other setting uses; an argument acts directly, same as /model.
2580
+ // /analytics — the usage count, paused for now: it explains why and offers no switch.
2426
2581
  // /feedback — a message to the maintainer, from inside the app. No account, no issue
2427
2582
  // tracker, no mail client: the whole point is that it costs the sender nothing. What
2428
2583
  // leaves the machine is decided in cli/feedback.ts and shown here before it goes.
@@ -2459,16 +2614,9 @@ export function App({ resumeSessionId, initialScreen }) {
2459
2614
  }
2460
2615
  }
2461
2616
  if (name === "/analytics") {
2462
- const verb = arg.trim().toLowerCase();
2463
- if (verb === "on" || verb === "off") {
2464
- setAnalyticsEnabled(verb === "on");
2465
- note(`Analytics turned ${verb}.`);
2466
- return;
2467
- }
2468
- if (verb) {
2469
- return say("/analytics on|off, or bare to open the switch.");
2470
- }
2471
- setOverlay({ kind: "analytics" });
2617
+ // Counting is paused (see analytics.ts), so there is nothing to choose: the command only
2618
+ // explains why. Whatever follows it on the line gets the same answer.
2619
+ note(ANALYTICS_EXPLANATION);
2472
2620
  return;
2473
2621
  }
2474
2622
  // /clear — a fresh conversation without leaving the folder. Until now the only
@@ -2534,6 +2682,41 @@ export function App({ resumeSessionId, initialScreen }) {
2534
2682
  say(text);
2535
2683
  return;
2536
2684
  }
2685
+ // /context limit [<tokens>|reset] [global] — the same override Settings > Usage >
2686
+ // Context sets in mwcode; see core/contextSettings.ts for the shared logic.
2687
+ if (/^limit\b/.test(arg.trim())) {
2688
+ const rest = arg.trim().slice("limit".length).trim();
2689
+ const global = /(^|\s)global(\s|$)/i.test(rest);
2690
+ const cleaned = rest.replace(/(^|\s)global(\s|$)/i, " ").trim();
2691
+ const scope = global ? "global" : "project";
2692
+ if (/^reset$/i.test(cleaned)) {
2693
+ await resetContextOverride(s, scope, s.governance.forbidden.root);
2694
+ note(`cleared the ${global ? "all-projects" : "project"} context limit.`);
2695
+ }
2696
+ else if (cleaned) {
2697
+ const n = Number(cleaned.replace(/[,_]/g, ""));
2698
+ if (!Number.isFinite(n) || n <= 0) {
2699
+ say("Give a token count, e.g. /context limit 200000, or /context limit 200000 global.");
2700
+ return;
2701
+ }
2702
+ const result = await setContextOverride(s, scope, n, s.governance.forbidden.root);
2703
+ if (!result.ok) {
2704
+ say(result.error);
2705
+ return;
2706
+ }
2707
+ }
2708
+ const view = await contextView(s, s.governance.forbidden.root);
2709
+ const sourceLabel = view.effective.source === "project"
2710
+ ? "this project's own limit"
2711
+ : view.effective.source === "global"
2712
+ ? "your all-projects limit"
2713
+ : `Mindweave's default for ${view.model}`;
2714
+ say(`Compacts at ${formatTokens(view.effective.tokens)} — ${sourceLabel}.\n\n` +
2715
+ ` ${view.recommendation.note}\n\n` +
2716
+ ` /context limit <tokens> sets it for this project, add "global" for every project.\n` +
2717
+ ` /context limit reset clears it back to the default (add "global" to clear that scope).`);
2718
+ return;
2719
+ }
2537
2720
  // Only a measurement taken against the CURRENT model is passed through; a figure
2538
2721
  // carried over from another provider's tool serialisation would be the largest
2539
2722
  // single error in the table, and the table is meant to settle arguments.
@@ -2541,9 +2724,51 @@ export function App({ resumeSessionId, initialScreen }) {
2541
2724
  const overhead = measured && measured.model === s.modelConfig.model ? measured.tokens : undefined;
2542
2725
  const budget = contextBudget(s.transcript, overhead);
2543
2726
  note("context breakdown:");
2544
- say(`${formatBudget(budget, sharpContextWindow(s.modelConfig.model), autoCompactThreshold(s.modelConfig.model))}
2727
+ say(`${formatBudget(budget, sharpContextWindow(s.modelConfig.model), effectiveAutoCompactThreshold(s))}
2545
2728
 
2546
- /context project shows what Mindweave read about this directory at startup.`);
2729
+ /context project shows what Mindweave read about this directory at startup.
2730
+ /context limit shows or sets your own compaction limit.`);
2731
+ return;
2732
+ }
2733
+ // /app — the desktop app. One screen with one choice: Enter opens the download page in the
2734
+ // user's browser. It is a screen rather than a bare link so nothing opens by surprise.
2735
+ if (name === "/app") {
2736
+ setOverlay({ kind: "app" });
2737
+ return;
2738
+ }
2739
+ // /rewind — go back to before one of your messages: the conversation AND the files
2740
+ // its turns changed, with the message handed back to edit. See memory/rewind.ts.
2741
+ if (name === "/rewind") {
2742
+ const items = rewindPoints(s);
2743
+ if (items.length === 0) {
2744
+ say("Nothing to rewind to yet. Send a message first.");
2745
+ return;
2746
+ }
2747
+ setOverlay({ kind: "rewind", items });
2748
+ return;
2749
+ }
2750
+ // /marathon — hand over a goal and let it run on its own. Bare arms the box (the next
2751
+ // message is the goal) or carries on a run that was stopped; `clear` takes a run down;
2752
+ // with text it starts straight away. Questions are asked at the start only.
2753
+ if (name === "/marathon") {
2754
+ const rest = arg.trim();
2755
+ if (rest === "clear") {
2756
+ await dismissMarathon(s); // saved too, so it stays closed when the session is reopened
2757
+ setMarathonUi(null);
2758
+ note("marathon cleared.");
2759
+ return;
2760
+ }
2761
+ if (rest) {
2762
+ await beginMarathon(s, rest);
2763
+ return;
2764
+ }
2765
+ if (s.marathon?.status === "running") {
2766
+ const state = s.marathon;
2767
+ setMarathonUi((ui) => marathonUiReduce(ui, { type: "restore", goal: state.goal, turn: state.turnsSpent, todos: state.todos ?? [] }));
2768
+ await driveMarathon(s, (options) => resumeMarathon(s, options));
2769
+ return;
2770
+ }
2771
+ setMarathonUi((ui) => marathonUiReduce(ui, { type: "arm" }));
2547
2772
  return;
2548
2773
  }
2549
2774
  // /undo — roll back file changes. Bare undoes the last turn; `list` shows what's
@@ -2559,7 +2784,7 @@ export function App({ resumeSessionId, initialScreen }) {
2559
2784
  // A resumed session genuinely has no history to undo, which is NOT the same
2560
2785
  // as nothing having happened — say which.
2561
2786
  say(cp?.wasResumed()
2562
- ? "Nothing to undo here — undo history isn't carried across restarts. Changes from the earlier run are still on disk."
2787
+ ? "Nothing to undo here. This session has no saved undo history, so changes from its earlier runs stay as they are."
2563
2788
  : "Nothing to undo — no file changes have been made yet this session.");
2564
2789
  return;
2565
2790
  }
@@ -2591,6 +2816,9 @@ export function App({ resumeSessionId, initialScreen }) {
2591
2816
  // separate note/say text lines — same restored/conflict/failed/skipped facts,
2592
2817
  // rendered the same way an edit's diff or a command's output is.
2593
2818
  if (restored.length > 0) {
2819
+ // A memory, rule, skill or MCP server can be among them; the live session has to
2820
+ // match what is on disk again.
2821
+ await reloadAgentState(s, restored);
2594
2822
  // The files on disk are back to their pre-turn state; drop them from the read
2595
2823
  // ledger so the model must re-read before it can edit them again.
2596
2824
  for (const p of restored)
@@ -2987,6 +3215,14 @@ export function App({ resumeSessionId, initialScreen }) {
2987
3215
  const s = session.current;
2988
3216
  if (!s)
2989
3217
  return;
3218
+ // /marathon armed the box: this message is the goal.
3219
+ if (marathonUiRef.current?.phase === "armed") {
3220
+ await beginMarathon(s, trimmed);
3221
+ return;
3222
+ }
3223
+ // A finished run's box has said its piece; the next ordinary message takes it down.
3224
+ if (marathonUiRef.current && isFinished(marathonUiRef.current))
3225
+ setMarathonUi(null);
2990
3226
  // The chat shows the typed line — `@mentions` stay visible, a dragged/dropped
2991
3227
  // file path collapses to just its name — never the file dump. The model gets
2992
3228
  // the full content via resolved <attached_file> blocks, and each attachment
@@ -3035,7 +3271,7 @@ export function App({ resumeSessionId, initialScreen }) {
3035
3271
  return (_jsx(TrustGate, { rows: rows, cwd: startCwd.current, breadth: trustBreadth, warning: breadthWarning(trustBreadth, startCwd.current), persists: trustPersists(trustBreadth), version: versionLabel(), docsUrl: MINDWEAVE_DOCS_URL, onTrust: () => {
3036
3272
  rememberTrust(projectDir(startCwd.current), trustBreadth);
3037
3273
  setTrustOpen(false);
3038
- }, onQuit: () => exit() }));
3274
+ }, onQuit: () => process.exit(0) }));
3039
3275
  }
3040
3276
  // FIRST RUN: nothing can run yet, so offer every provider rather than one. The user
3041
3277
  // adds as many keys as they like and continues when they are ready. See KeySetup.
@@ -3068,7 +3304,10 @@ export function App({ resumeSessionId, initialScreen }) {
3068
3304
  // above where you are scrolled to, reads as the app having ignored you.
3069
3305
  setScrollUp(0);
3070
3306
  setHistory((h) => (h[h.length - 1] === text ? h : [...h, text]));
3071
- if (busy) {
3307
+ // Also when something is already queued, busy or not: a turn ends, the next queued message is
3308
+ // about to be sent, and for one render `busy` reads false. A message typed in that moment used to
3309
+ // go straight out and arrive BEFORE the ones that had been waiting.
3310
+ if (busy || queueRef.current.length > 0) {
3072
3311
  queueRef.current.push(queueMessage(text, { interrupting: interrupting.current }));
3073
3312
  setQueued([...queueRef.current]);
3074
3313
  return;
@@ -3126,13 +3365,20 @@ export function App({ resumeSessionId, initialScreen }) {
3126
3365
  if (!overlay)
3127
3366
  return null;
3128
3367
  const cur = session.current;
3129
- if (overlay.kind === "analytics") {
3130
- const enabled = analyticsEnabled();
3131
- const items = [
3132
- { label: "On" + (enabled ? " ✓" : ""), description: "sends the ping" },
3133
- { label: "Off" + (!enabled ? " ✓" : ""), description: "sends nothing" },
3134
- ];
3135
- return (_jsx(Picker, { title: "Anonymous usage analytics", note: ANALYTICS_EXPLANATION, items: items, width: width, maxRows: maxRows, initialIndex: enabled ? 0 : 1, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
3368
+ if (overlay.kind === "app") {
3369
+ return (_jsx(Picker, { title: "Mindweave desktop app", note: APP_EXPLANATION, items: [{ label: "Open the download page", description: "opens your default browser" }], width: width, maxRows: maxRows, initialIndex: 0, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
3370
+ }
3371
+ if (overlay.kind === "rewind") {
3372
+ const items = overlay.items.map((p) => ({
3373
+ label: p.text,
3374
+ description: [timeAgo(p.at), changeSummary(p)].filter(Boolean).join(" · "),
3375
+ }));
3376
+ return (_jsx(Picker, { title: "Rewind to before which message?", items: items, width: width, maxRows: maxRows, rightAlignDescription: true, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
3377
+ }
3378
+ if (overlay.kind === "rewindConfirm") {
3379
+ const p = overlay.point;
3380
+ const quoted = p.text.length > 48 ? `${p.text.slice(0, 47)}…` : p.text;
3381
+ return (_jsx(Picker, { title: `Rewind to before “${quoted}”?`, items: rewindChoices(p).map(({ label, description }) => ({ label, description })), describeSelection: true, width: width, maxRows: maxRows, onSelect: onOverlaySelect, onCancel: onOverlayCancel }));
3136
3382
  }
3137
3383
  if (overlay.kind === "sessions") {
3138
3384
  const items = overlay.items.map((m) => ({
@@ -3163,8 +3409,10 @@ export function App({ resumeSessionId, initialScreen }) {
3163
3409
  const n = modelsOf(p).length;
3164
3410
  const models = `${n} model${n === 1 ? "" : "s"}`;
3165
3411
  // Say up front which ones you can actually run — finding out at the next
3166
- // request is the worse place to learn it.
3167
- const key = hasApiKey(p.apiKeyEnv) ? "key set" : `needs ${p.apiKeyEnv}`;
3412
+ // request is the worse place to learn it. A local runtime has no key: it runs or not.
3413
+ const key = p.local
3414
+ ? hasApiKey(p.apiKeyEnv) ? "running on this machine" : n > 0 ? "not running" : "not running, or no models pulled"
3415
+ : hasApiKey(p.apiKeyEnv) ? "key set" : `needs ${p.apiKeyEnv}`;
3168
3416
  const here = p.id === active ? ` · on ${activeLabel}` : "";
3169
3417
  return { label: p.label + (p.id === active ? " ✓" : ""), description: `${models} · ${key}${here}` };
3170
3418
  });
@@ -3248,6 +3496,10 @@ export function App({ resumeSessionId, initialScreen }) {
3248
3496
  // authority for the frame: no min or max against the polled state, which can lag a
3249
3497
  // resize, and a live syscall never can.
3250
3498
  const liveRows = liveTerminalSize(stdout).rows;
3499
+ // The header is fitted to the width RIGHT NOW as well. Fitted to the debounced width, for the
3500
+ // moment after a narrowing resize it was laid out for the old, wider window and wrapped its
3501
+ // words over two rows.
3502
+ const liveCols = liveTerminalSize(stdout).columns;
3251
3503
  // The FULL height, so the footer sits on the very last row with nothing below it.
3252
3504
  //
3253
3505
  // This was `liveRows - 1` for a real reason that no longer applies. Ink's own renderer
@@ -3335,10 +3587,9 @@ export function App({ resumeSessionId, initialScreen }) {
3335
3587
  */
3336
3588
  const frameTop = readingInline ? Math.max(0, rows - frameHeight) : 0;
3337
3589
  pillHit.current = pill !== null && pillRow.current !== null ? pillBounds(pill, width, frameTop + pillRow.current) : null;
3338
- // Only the blocks that can still be reached are worth laying out. Yoga lays
3339
- // out every child on every render — including one caused by a keystroke — so
3340
- // an unbounded transcript makes typing slower the longer you have been
3341
- // talking. This is generous enough to scroll through comfortably.
3590
+ // The whole conversation stays reachable: the cap is only a safety valve (see
3591
+ // SCROLLBACK_BLOCKS). Blocks outside the window cost one number each here and nothing
3592
+ // in Yoga, which only ever sees the window and its spacers.
3342
3593
  const rendered = allBlocks.length > SCROLLBACK_BLOCKS ? allBlocks.slice(-SCROLLBACK_BLOCKS) : allBlocks;
3343
3594
  const offset = allBlocks.length - rendered.length;
3344
3595
  // Which of those actually reach Yoga. Everything else becomes a spacer of exactly
@@ -3416,16 +3667,16 @@ export function App({ resumeSessionId, initialScreen }) {
3416
3667
  // The status line, the queued bar, the input and whatever sits under it. Built once
3417
3668
  // and rendered by BOTH shells: the only thing that differs between them is where it
3418
3669
  // ends up — pinned to the bottom of a frame we own, or simply the last thing printed.
3419
- const footerView = (_jsxs(Box, { ref: footerRef, flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { flexShrink: 0, children: _jsx(StatusLine, { busy: busy, startedAt: turnStart.current, lastMs: lastMs, usage: taskUsage, received: liveTokens, advance: advanceTokens }) }), _jsx(Box, { flexShrink: 0, children: _jsx(QueuedBar, { queued: queued }) }), _jsx(Box, { flexShrink: 0, flexDirection: "column", children: ready ? (_jsx(PromptInput, { onSubmit: onSend, opening: opening, disabled: false, placeholder: busy ? "type to queue a message…" : "say something…", width: width, history: history, completions: completions, pathComplete: pathComplete.current, onLargePaste: registerPaste, onDroppedPaths: (text) => dropHandles.current.register(text), registerCaretClick: (place) => {
3670
+ const footerView = (_jsxs(Box, { ref: footerRef, flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { children: " " }) }), _jsx(Box, { flexShrink: 0, children: _jsx(StatusLine, { busy: busy, startedAt: turnStart.current, lastMs: lastMs, usage: taskUsage, received: liveTokens, advance: advanceTokens }) }), _jsx(Box, { flexShrink: 0, children: _jsx(QueuedBar, { queued: queued }) }), marathonUi ? (_jsx(Box, { flexShrink: 0, children: _jsx(MarathonBox, { ui: marathonUi, width: width, cap: shell === "inline" ? 4 : 7 }) })) : null, _jsx(Box, { flexShrink: 0, flexDirection: "column", children: ready ? (_jsx(PromptInput, { onSubmit: onSend, opening: opening, disabled: false, placeholder: marathonUi?.phase === "armed" ? "describe the goal…" : busy ? "type to queue a message…" : "say something…", width: width, history: history, completions: completions, pathComplete: pathComplete.current, onLargePaste: registerPaste, onDroppedPaths: (text) => dropHandles.current.register(text), registerCaretClick: (place) => {
3420
3671
  caretClick.current = place;
3421
3672
  }, registerTextSelect: (select) => {
3422
3673
  textSelect.current = select;
3423
- }, maxMenuRows: maxMenuItems, menuAbove: shell === "inline", settleKey: committed.length, onMenuChange: onMenuChange, placeCursor: shell === "inline", onQueuePop: popQueue, overlay: overlayView })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
3674
+ }, maxMenuRows: maxMenuItems, menuAbove: shell === "inline", settleKey: committed.length, onMenuChange: onMenuChange, placeCursor: shell === "inline", onQueuePop: popQueue, overlay: overlayView, fill: fill })) : (_jsx(Box, { paddingX: 1, children: _jsx(Text, { dimColor: true, children: "starting\u2026" }) })) }), ctxWarn ? (
3424
3675
  // A compaction is near. This takes the line over the tip and the background bar:
3425
3676
  // it is the one thing here the user needs BEFORE it happens, since the summarizing
3426
3677
  // pass rewrites the conversation. Dim as it approaches, then a plain warning colour
3427
3678
  // with the manual way out once it is about to fire on its own.
3428
- _jsx(Box, { flexShrink: 0, children: ctxWarn.percentLeft <= 5 ? (_jsx(Text, { color: "yellow", children: " context low · /compact to summarize now" })) : (_jsx(Text, { dimColor: true, children: ` ${ctxWarn.percentLeft}% until auto-compact` })) })) : !overlayView && runningShells.length > 0 ? (_jsx(Box, { flexShrink: 0, children: _jsx(BackgroundBar, { shells: runningShells }) })) : (_jsx(TipLine, { tip: TIPS[tipIdx % TIPS.length] }))] }));
3679
+ _jsx(Box, { flexShrink: 0, children: ctxWarn.percentLeft <= 5 ? (_jsx(Text, { color: WARN, children: " context low · /compact to summarize now" })) : (_jsx(Text, { dimColor: true, children: ` ${ctxWarn.percentLeft}% until auto-compact` })) })) : !overlayView && runningShells.length > 0 ? (_jsx(Box, { flexShrink: 0, children: _jsx(BackgroundBar, { shells: runningShells }) })) : (_jsx(TipLine, { tip: TIPS[tipIdx % TIPS.length] }))] }));
3429
3680
  // The transcript viewport: the scrolled window, its measurements, and the chip.
3430
3681
  //
3431
3682
  // Built once and rendered by BOTH shells — the full-screen frame, and the inline
@@ -3618,7 +3869,7 @@ export function App({ resumeSessionId, initialScreen }) {
3618
3869
  // reprint the whole conversation underneath this frame.
3619
3870
  chatView) : (tail.map((b, i) => (_jsx(BlockView, { block: b, columns: width, tightTop: isTight(allBlocks, committed.length + i) }, b.id)))), footerView] })] }));
3620
3871
  }
3621
- return (_jsxs(Box, { flexDirection: "column", height: frameHeight, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Banner, { width: width, mode: mode, modelConfig: session.current?.modelConfig, busy: busy }) }), chatView, footerView] }));
3872
+ return (_jsxs(Box, { flexDirection: "column", height: frameHeight, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Banner, { width: liveCols, mode: mode, modelConfig: session.current?.modelConfig, busy: busy }) }), chatView, footerView] }));
3622
3873
  }
3623
3874
  /**
3624
3875
  * The inline shell's header, printed once at the top of the conversation.
@@ -3630,7 +3881,7 @@ export function App({ resumeSessionId, initialScreen }) {
3630
3881
  * header. What belongs in scrollback is the part that cannot go stale.
3631
3882
  */
3632
3883
  function InlineHeader() {
3633
- return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: "Mindweave" }), _jsxs(Text, { dimColor: true, children: [" ", versionLabel()] })] }));
3884
+ return (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: ACCENT, children: "Mindweave" }), _jsxs(Text, { dimColor: true, children: [" ", versionLabel()] })] }));
3634
3885
  }
3635
3886
  /** A sentinel <Static> item: the one-time header, printed with the transcript so it
3636
3887
  * scrolls away rather than being redrawn above every frame. */
@@ -3655,20 +3906,15 @@ const MENU_CHROME_ROWS = 4;
3655
3906
  const INLINE_MENU_ROWS = 3;
3656
3907
  /** Lines PageUp/PageDown move per press. */
3657
3908
  const PAGE_LINES = 10;
3658
- /** Lines one wheel notch moves. Three is the usual terminal step, and a flick
3659
- * sends several reports, so it accumulates into a natural glide. */
3660
- const WHEEL_LINES = 3;
3661
- /** How much of the transcript stays scrollable. Every rendered block is laid out
3662
- * on every render, so this bounds what typing costs in a long conversation. */
3663
- const SCROLLBACK_BLOCKS = 150;
3664
3909
  /** Blocks reprinted when the inline shell is entered. Two screens or so: enough to look
3665
3910
  * back over, few enough that the reprint is not a visible pause. */
3666
3911
  /** How long the INLINE shell waits for a drag to settle before re-reading the size.
3667
3912
  * The full-screen shell does not wait — see useTerminalSize. */
3668
3913
  const RESIZE_SETTLE_MS = 150;
3669
3914
  /** How often the size is polled, for Windows consoles where the resize event may never
3670
- * fire at all (nodejs/node#13197). Two integer reads; an unchanged size costs nothing. */
3671
- const RESIZE_POLL_MS = 250;
3915
+ * fire at all (nodejs/node#13197). Two integer reads; an unchanged size costs nothing. 80ms keeps
3916
+ * the frame laid out for the old width (a header wrapping over two rows) to a single blink. */
3917
+ const RESIZE_POLL_MS = 80;
3672
3918
  const INLINE_REPRINT_BLOCKS = 40;
3673
3919
  /**
3674
3920
  * The loom shuttle that runs beside the name while a turn is working.
@@ -3707,7 +3953,7 @@ function Shuttle({ busy }) {
3707
3953
  return () => clearInterval(id);
3708
3954
  }, [busy, stops]);
3709
3955
  if (!busy)
3710
- return _jsx(Text, { dimColor: true, children: "─".repeat(SHUTTLE_CELLS) });
3956
+ return _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "─".repeat(SHUTTLE_CELLS) });
3711
3957
  // Fold the counter back on itself so the shuttle returns instead of wrapping.
3712
3958
  const pos = step < stops ? step : stops * 2 - 2 - step;
3713
3959
  let track = "";
@@ -3716,10 +3962,24 @@ function Shuttle({ busy }) {
3716
3962
  const right = cell * 2 + 1 >= pos && cell * 2 + 1 < pos + SHUTTLE_SPAN;
3717
3963
  track += left && right ? "━" : left ? "╾" : right ? "╼" : "─";
3718
3964
  }
3719
- return _jsx(Text, { color: "yellow", children: track });
3965
+ return _jsx(Text, { color: ACCENT, dimColor: true, wrap: "truncate-end", children: track });
3720
3966
  }
3721
- export function Banner({ width, mode, modelConfig, busy }) {
3967
+ export function Banner({ width: given, mode, modelConfig, busy }) {
3722
3968
  const m = modeById(mode);
3969
+ // Re-fitted the moment the window changes. On a resize Ink lays the existing frame out again at
3970
+ // the new width before anything re-renders, so a header fitted to the old width wrapped for that
3971
+ // first frame. Listening here re-renders it straight away, and `wrap="truncate-end"` below keeps
3972
+ // even that first frame on one row.
3973
+ const { stdout } = useStdout();
3974
+ const [liveWidth, setLiveWidth] = useState(given);
3975
+ useEffect(() => {
3976
+ const on = () => setLiveWidth(liveTerminalSize(stdout).columns);
3977
+ stdout.on("resize", on);
3978
+ return () => {
3979
+ stdout.off("resize", on);
3980
+ };
3981
+ }, [stdout]);
3982
+ const width = Math.min(given, liveWidth);
3723
3983
  // The release name, not the raw semver — the version stays available through
3724
3984
  // --help and the update-check note; this bar is read constantly during a working
3725
3985
  // turn and has no room to spare for a number nobody is reading it for.
@@ -3730,17 +3990,17 @@ export function Banner({ width, mode, modelConfig, busy }) {
3730
3990
  // (it is the same one the mode uses everywhere else); the model gets the teal of the
3731
3991
  // "reaching outside the machine" family; and the effort level is plain white, the
3732
3992
  // brightest thing in the row, because it is what changes most often.
3733
- const modeText = `${m.name.toUpperCase()} MODE ON`;
3734
- const modelText = modelConfig ? modelLabel(modelConfig.model) : "";
3735
- const effortText = modelConfig ? thinkLabel(modelConfig).toUpperCase() : "";
3736
- const right = modelConfig ? `${modeText} | ${modelText} | ${effortText}` : modeText;
3737
- // The title row gets a 1-col inset (same idea as the box's own paddingX),
3738
- // but the rule spans the FULL width, edge to edge — same as the box's
3739
- // border below it, so the two anchor the screen the same way instead of
3740
- // the header floating in from the sides while the box touches both edges.
3741
- const innerWidth = Math.max(1, width - 2);
3742
- const gap = Math.max(1, innerWidth - left.length - 1 - SHUTTLE_CELLS - right.length);
3743
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: left }), _jsx(Text, { children: " " }), _jsx(Shuttle, { busy: busy }), _jsx(Text, { children: " ".repeat(gap) }), _jsx(Text, { dimColor: true, color: m.color, children: modeText }), modelConfig ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { color: KIND_COLOR.websearch, children: modelText }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { children: effortText })] })) : null] }), _jsx(Text, { dimColor: true, children: "─".repeat(width) })] }));
3993
+ // Fitted to ONE row whatever the width, model or mode (see bannerFit.ts): left to the layout,
3994
+ // a header too long for the window broke its words over two rows.
3995
+ const fit = fitBanner(Math.max(1, width - 2), {
3996
+ title: left,
3997
+ mode: m.name.toUpperCase(),
3998
+ model: modelConfig ? modelLabel(modelConfig.model) : "",
3999
+ effort: modelConfig ? thinkLabel(modelConfig).toUpperCase() : "",
4000
+ shuttle: SHUTTLE_CELLS,
4001
+ });
4002
+ const status = [fit.model, fit.effort].filter(Boolean);
4003
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [fit.title ? _jsx(Text, { bold: true, color: ACCENT, wrap: "truncate-end", children: fit.title }) : null, fit.title && fit.showShuttle ? _jsx(Text, { children: " " }) : null, fit.showShuttle ? _jsx(Shuttle, { busy: busy }) : null, _jsx(Text, { wrap: "truncate-end", children: " ".repeat(fit.gap) }), _jsx(Text, { dimColor: true, color: m.color, wrap: "truncate-end", children: fit.mode }), status.map((part, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { children: part })] }, i)))] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "─".repeat(width) })] }));
3744
4004
  }
3745
4005
  /**
3746
4006
  * Terminal size that updates on resize — DEBOUNCED. A drag-resize fires a flood
@@ -3823,13 +4083,24 @@ export function useTerminalSize(defer) {
3823
4083
  read();
3824
4084
  return;
3825
4085
  }
3826
- debounce = setTimeout(read, RESIZE_SETTLE_MS);
4086
+ debounce = setTimeout(() => {
4087
+ debounce = undefined;
4088
+ read();
4089
+ }, RESIZE_SETTLE_MS);
3827
4090
  };
3828
4091
  stdout.on("resize", onResize);
4092
+ // The poll must never restart a settle wait already running: it fires more often than the
4093
+ // inline shell's settle delay, so restarting it would postpone the read for ever.
4094
+ const onPoll = () => {
4095
+ if (!deferRef.current)
4096
+ read();
4097
+ else if (!debounce)
4098
+ onResize();
4099
+ };
3829
4100
  // The poll exists for Windows, where the event cannot be relied on at all. It goes
3830
4101
  // through the same handler, so it inherits whichever policy the shell is using, and
3831
4102
  // the identical-size check above makes a poll that finds nothing free.
3832
- const poll = setInterval(onResize, RESIZE_POLL_MS);
4103
+ const poll = setInterval(onPoll, RESIZE_POLL_MS);
3833
4104
  // The size read at THIS exact instant can be stale too: entering alt-screen
3834
4105
  // (a raw escape code written before Ink even mounts, see altScreen.ts) makes
3835
4106
  // the terminal reconfigure its buffer, and querying dimensions mid-reconfigure
@@ -3887,9 +4158,12 @@ function timeAgo(ms) {
3887
4158
  */
3888
4159
  function StatusLine({ busy, startedAt, lastMs, usage, received, advance, }) {
3889
4160
  // The render clock while busy: it advances the elapsed timer AND steps the eased token
3890
- // counter one frame. 50ms, which is what makes the number read as counting rather than
3891
- // as jumping — at 1Hz it moved once a second in whatever lump had arrived, which is a
3892
- // stutter, not an animation. Nothing else re-renders with it: the tick is this
4161
+ // counter one frame. It runs at about the display's pace (16ms): the number reads as counting
4162
+ // rather than as jumping, and the working dot moves smoothly. It was 50ms (20 frames a
4163
+ // second), which was the slowest-looking thing on a screen that could draw three times as
4164
+ // often, and at 1Hz before that it moved once a second in whatever lump had arrived, which
4165
+ // is a stutter, not an animation. A tick costs about a millisecond now that unchanged rows
4166
+ // are not rebuilt (see inkOutputPatch.ts). Nothing else re-renders with it: the tick is this
3893
4167
  // component's own state and the figure is read through a getter, so the cost is one
3894
4168
  // small subtree per frame.
3895
4169
  const [, tick] = useState(0);
@@ -3906,7 +4180,7 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, }) {
3906
4180
  if (busy && startedAt != null) {
3907
4181
  // `< Scampering… < 45s · ↓ 1.8k tokens >` — the reference shape. The angle brackets
3908
4182
  // are what make it read as a live gauge rather than a sentence, and the verb is
3909
- // held for the whole turn (see workingVerb) so the line does not flicker between
4183
+ // swapped every few seconds (see workingVerb) and steady inside each, so the line does not flicker between
3910
4184
  // words while the seconds tick.
3911
4185
  const secs = Math.floor((Date.now() - startedAt) / 1000);
3912
4186
  // OUTPUT tokens for THIS task, estimated from what has streamed back. It is the task's
@@ -3915,7 +4189,7 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, }) {
3915
4189
  // did. Output is
3916
4190
  // also the only thing that grows continuously while a turn runs, so it animates cleanly.
3917
4191
  const got = received();
3918
- label = (_jsxs(Text, { children: [" ", workingVerb(startedAt), "\u2026", " ", _jsxs(Text, { dimColor: true, children: ["< ", fmtElapsed(secs), got > 0 ? ` · ↓ ${formatTokens(got)} tokens` : "", " >"] })] }));
4192
+ label = (_jsxs(Text, { children: [" ", workingVerb(startedAt, Date.now() - startedAt), "\u2026", " ", _jsxs(Text, { dimColor: true, children: ["< ", fmtElapsed(secs), got > 0 ? ` · ↓ ${formatTokens(got)} tokens` : "", " >"] })] }));
3919
4193
  }
3920
4194
  else if (lastMs != null) {
3921
4195
  // Settled receipt: elapsed time and the turn's real token cost, shown at once,
@@ -3936,13 +4210,14 @@ function StatusLine({ busy, startedAt, lastMs, usage, received, advance, }) {
3936
4210
  // no line at all, rather than a marker floating with nothing beside it.
3937
4211
  if (!busy && label === null)
3938
4212
  return null;
3939
- const dot = busy ? _jsx(Text, { color: "cyan", children: "\u25CF" }) : _jsx(Text, { dimColor: true, children: "\u25CF" });
4213
+ // Working: the accent green breathing on the same clock as the tool rows' dots. Done: still and dim.
4214
+ const dot = busy ? _jsx(PulseDot, { tone: "green" }) : _jsx(Text, { dimColor: true, children: "\u25CF" });
3940
4215
  // No marginTop: the footer owns the gap above itself now, so that it survives this
3941
4216
  // component rendering nothing at all. Two would read as a hole.
3942
4217
  return (_jsxs(Box, { marginBottom: 0, children: [dot, label] }));
3943
4218
  }
3944
- /** The working line's render clock. See StatusLine's tick. */
3945
- const LIVE_TICK_MS = 50;
4219
+ /** The working line's render clock, in milliseconds. See StatusLine's tick. */
4220
+ const LIVE_TICK_MS = 16;
3946
4221
  function fmtElapsed(s) {
3947
4222
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
3948
4223
  }
@@ -3980,7 +4255,7 @@ function BackgroundBar({ shells }) {
3980
4255
  const cmds = shells
3981
4256
  .map((s) => `$ ${clipCmd(s.command)}${s.port ? ` (${s.port})` : ""} ${bgClock(now - s.startedAt)}`)
3982
4257
  .join(" • ");
3983
- return (_jsxs(Box, { children: [_jsx(Text, { color: "yellow", children: "[BG] " }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${shells.length} running: ${cmds}` })] }));
4258
+ return (_jsxs(Box, { children: [_jsx(Text, { color: WARN, children: "[BG] " }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: `${shells.length} running: ${cmds}` })] }));
3984
4259
  }
3985
4260
  /** Elapsed for the background bar: whole seconds under a minute, `1m 20s` past it. */
3986
4261
  function bgClock(ms) {