mindweave 2.5.1 → 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.
- package/README.md +50 -114
- package/dist/cli/App.js +578 -303
- package/dist/cli/App.js.map +1 -1
- package/dist/cli/analytics.js +27 -18
- package/dist/cli/analytics.js.map +1 -1
- package/dist/cli/attachments.js +34 -0
- package/dist/cli/attachments.js.map +1 -1
- package/dist/cli/bannerFit.js +70 -0
- package/dist/cli/bannerFit.js.map +1 -0
- package/dist/cli/bootstrap.js +41 -3
- package/dist/cli/bootstrap.js.map +1 -1
- package/dist/cli/commands.js +4 -1
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/components/ApprovalBox.js +3 -2
- package/dist/cli/components/ApprovalBox.js.map +1 -1
- package/dist/cli/components/BlockView.js +27 -6
- package/dist/cli/components/BlockView.js.map +1 -1
- package/dist/cli/components/FirstRunFrame.js +2 -1
- package/dist/cli/components/FirstRunFrame.js.map +1 -1
- package/dist/cli/components/KeyManager.js +5 -4
- package/dist/cli/components/KeyManager.js.map +1 -1
- package/dist/cli/components/KeySetup.js +29 -13
- package/dist/cli/components/KeySetup.js.map +1 -1
- package/dist/cli/components/MarathonBox.js +64 -0
- package/dist/cli/components/MarathonBox.js.map +1 -0
- package/dist/cli/components/McpMinitabs.js +8 -7
- package/dist/cli/components/McpMinitabs.js.map +1 -1
- package/dist/cli/components/Picker.js +4 -3
- package/dist/cli/components/Picker.js.map +1 -1
- package/dist/cli/components/PromptInput.js +47 -8
- package/dist/cli/components/PromptInput.js.map +1 -1
- package/dist/cli/components/PulseDot.js +42 -0
- package/dist/cli/components/PulseDot.js.map +1 -0
- package/dist/cli/components/SubagentView.js +9 -6
- package/dist/cli/components/SubagentView.js.map +1 -1
- package/dist/cli/components/TipLine.js +2 -1
- package/dist/cli/components/TipLine.js.map +1 -1
- package/dist/cli/components/ToolGroup.js +65 -49
- package/dist/cli/components/ToolGroup.js.map +1 -1
- package/dist/cli/components/ToolLine.js +20 -19
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/components/TrustGate.js +2 -1
- package/dist/cli/components/TrustGate.js.map +1 -1
- package/dist/cli/components/minitabs.js +3 -2
- package/dist/cli/components/minitabs.js.map +1 -1
- package/dist/cli/desktopApp.js +45 -0
- package/dist/cli/desktopApp.js.map +1 -0
- package/dist/cli/help.js +1 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inkOutputPatch.js +285 -0
- package/dist/cli/inkOutputPatch.js.map +1 -0
- package/dist/cli/inkSpeed.js +29 -0
- package/dist/cli/inkSpeed.js.map +1 -0
- package/dist/cli/inkSpeedLoader.js +47 -0
- package/dist/cli/inkSpeedLoader.js.map +1 -0
- package/dist/cli/inputText.js +30 -0
- package/dist/cli/inputText.js.map +1 -0
- package/dist/cli/keyManager.js +2 -1
- package/dist/cli/keyManager.js.map +1 -1
- package/dist/cli/keyPrefs.js +90 -0
- package/dist/cli/keyPrefs.js.map +1 -0
- package/dist/cli/keySetup.js +5 -2
- package/dist/cli/keySetup.js.map +1 -1
- package/dist/cli/marathonUi.js +113 -0
- package/dist/cli/marathonUi.js.map +1 -0
- package/dist/cli/markdown.js +63 -11
- package/dist/cli/markdown.js.map +1 -1
- package/dist/cli/modes.js +3 -3
- package/dist/cli/modes.js.map +1 -1
- package/dist/cli/prodEnv.js +43 -0
- package/dist/cli/prodEnv.js.map +1 -0
- package/dist/cli/revealPace.js +5 -5
- package/dist/cli/revealPace.js.map +1 -1
- package/dist/cli/revealQueue.js +120 -0
- package/dist/cli/revealQueue.js.map +1 -0
- package/dist/cli/theme.js +47 -0
- package/dist/cli/theme.js.map +1 -0
- package/dist/cli/toolDisplay.js +112 -18
- package/dist/cli/toolDisplay.js.map +1 -1
- package/dist/cli/transcript.js +1 -1
- package/dist/cli/transcript.js.map +1 -1
- package/dist/cli/virtualWindow.js +14 -2
- package/dist/cli/virtualWindow.js.map +1 -1
- package/dist/cli/wheelAccel.js +73 -0
- package/dist/cli/wheelAccel.js.map +1 -0
- package/dist/cli/widestLinePatch.js +48 -0
- package/dist/cli/widestLinePatch.js.map +1 -0
- package/dist/cli/workingVerb.js +88 -28
- package/dist/cli/workingVerb.js.map +1 -1
- package/dist/cli/wrap.js +1 -1
- package/dist/cli/wrap.js.map +1 -1
- package/dist/core/contextSettings.js +84 -0
- package/dist/core/contextSettings.js.map +1 -0
- package/dist/core/limitsView.js +40 -0
- package/dist/core/limitsView.js.map +1 -0
- package/dist/core/mcpServers.js +164 -0
- package/dist/core/mcpServers.js.map +1 -0
- package/dist/core/permissions.js +209 -0
- package/dist/core/permissions.js.map +1 -0
- package/dist/core/providerKeys.js +187 -0
- package/dist/core/providerKeys.js.map +1 -0
- package/dist/core/rulesSkills.js +142 -0
- package/dist/core/rulesSkills.js.map +1 -0
- package/dist/core/spendView.js +90 -0
- package/dist/core/spendView.js.map +1 -0
- package/dist/core/turnRunner.js +453 -60
- package/dist/core/turnRunner.js.map +1 -1
- package/dist/drivers/anthropic/client.js +24 -5
- package/dist/drivers/anthropic/client.js.map +1 -1
- package/dist/drivers/anthropic/manifest.js +15 -2
- package/dist/drivers/anthropic/manifest.js.map +1 -1
- package/dist/drivers/cerebras/manifest.js +6 -2
- package/dist/drivers/cerebras/manifest.js.map +1 -1
- package/dist/drivers/deepseek/manifest.js +12 -3
- package/dist/drivers/deepseek/manifest.js.map +1 -1
- package/dist/drivers/glm/manifest.js +3 -1
- package/dist/drivers/glm/manifest.js.map +1 -1
- package/dist/drivers/groq/manifest.js +5 -2
- package/dist/drivers/groq/manifest.js.map +1 -1
- package/dist/drivers/ollama/catalog.js +71 -0
- package/dist/drivers/ollama/catalog.js.map +1 -0
- package/dist/drivers/ollama/client.js +164 -0
- package/dist/drivers/ollama/client.js.map +1 -0
- package/dist/drivers/ollama/endpoint.js +42 -0
- package/dist/drivers/ollama/endpoint.js.map +1 -0
- package/dist/drivers/ollama/index.js +5 -0
- package/dist/drivers/ollama/index.js.map +1 -0
- package/dist/drivers/ollama/manifest.js +64 -0
- package/dist/drivers/ollama/manifest.js.map +1 -0
- package/dist/drivers/openai/client.js +10 -4
- package/dist/drivers/openai/client.js.map +1 -1
- package/dist/drivers/openai/manifest.js +46 -12
- package/dist/drivers/openai/manifest.js.map +1 -1
- package/dist/drivers/registry.js +11 -0
- package/dist/drivers/registry.js.map +1 -1
- package/dist/drivers/retryPolicy.js +26 -1
- package/dist/drivers/retryPolicy.js.map +1 -1
- package/dist/dynamo/auxModel.js +54 -0
- package/dist/dynamo/auxModel.js.map +1 -0
- package/dist/dynamo/engine.js +380 -99
- package/dist/dynamo/engine.js.map +1 -1
- package/dist/dynamo/guard.js +10 -0
- package/dist/dynamo/guard.js.map +1 -1
- package/dist/dynamo/liveMeter.js +25 -6
- package/dist/dynamo/liveMeter.js.map +1 -1
- package/dist/dynamo/marathon.js +409 -0
- package/dist/dynamo/marathon.js.map +1 -0
- package/dist/dynamo/marathonPrompt.js +45 -0
- package/dist/dynamo/marathonPrompt.js.map +1 -0
- package/dist/dynamo/model.js +6 -2
- package/dist/dynamo/model.js.map +1 -1
- package/dist/dynamo/toolBatches.js +58 -0
- package/dist/dynamo/toolBatches.js.map +1 -0
- package/dist/dynamo/usageLimits.js +446 -0
- package/dist/dynamo/usageLimits.js.map +1 -0
- package/dist/governor/freshness.js +10 -7
- package/dist/governor/freshness.js.map +1 -1
- package/dist/governor/index.js +76 -9
- package/dist/governor/index.js.map +1 -1
- package/dist/governor/write.js +43 -14
- package/dist/governor/write.js.map +1 -1
- package/dist/index.js +18 -151
- package/dist/index.js.map +1 -1
- package/dist/main.js +162 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp/manager.js +18 -0
- package/dist/mcp/manager.js.map +1 -1
- package/dist/memory/allMetas.js +69 -0
- package/dist/memory/allMetas.js.map +1 -0
- package/dist/memory/compaction.js +69 -14
- package/dist/memory/compaction.js.map +1 -1
- package/dist/memory/earlier.js +15 -0
- package/dist/memory/earlier.js.map +1 -0
- package/dist/memory/profile.js +93 -0
- package/dist/memory/profile.js.map +1 -0
- package/dist/memory/rewind.js +262 -0
- package/dist/memory/rewind.js.map +1 -0
- package/dist/memory/session.js +42 -10
- package/dist/memory/session.js.map +1 -1
- package/dist/memory/sessionMemory.js +48 -13
- package/dist/memory/sessionMemory.js.map +1 -1
- package/dist/memory/sessionMemoryCompact.js +1 -1
- package/dist/memory/sessionMemoryCompact.js.map +1 -1
- package/dist/memory/sessionTitle.js +50 -0
- package/dist/memory/sessionTitle.js.map +1 -0
- package/dist/memory/store.js +42 -1
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.js.map +1 -1
- package/dist/tools/askUser.js +14 -0
- package/dist/tools/askUser.js.map +1 -1
- package/dist/tools/backgroundShells.js +208 -5
- package/dist/tools/backgroundShells.js.map +1 -1
- package/dist/tools/browser.js +176 -0
- package/dist/tools/browser.js.map +1 -0
- package/dist/tools/cdp.js +165 -0
- package/dist/tools/cdp.js.map +1 -0
- package/dist/tools/checkpoints.js +208 -5
- package/dist/tools/checkpoints.js.map +1 -1
- package/dist/tools/deferredNative.js +2 -0
- package/dist/tools/deferredNative.js.map +1 -1
- package/dist/tools/exitPlan.js +4 -0
- package/dist/tools/exitPlan.js.map +1 -1
- package/dist/tools/hiddenDesktop.js +273 -0
- package/dist/tools/hiddenDesktop.js.map +1 -0
- package/dist/tools/listeningPorts.js +134 -0
- package/dist/tools/listeningPorts.js.map +1 -0
- package/dist/tools/mcpSearch.js +8 -0
- package/dist/tools/mcpSearch.js.map +1 -1
- package/dist/tools/mindweaveStatus.js +1 -1
- package/dist/tools/mindweaveStatus.js.map +1 -1
- package/dist/tools/registry.js +3 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/runCommand.js +107 -6
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/screenshot.js +4 -6
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/screenshotWin.js +26 -9
- package/dist/tools/screenshotWin.js.map +1 -1
- package/dist/tools/stateCheckpoint.js +128 -0
- package/dist/tools/stateCheckpoint.js.map +1 -0
- package/dist/tools/todo.js +6 -0
- package/dist/tools/todo.js.map +1 -1
- package/dist/tools/ui.js +1295 -0
- package/dist/tools/ui.js.map +1 -0
- package/dist/tools/uiHide.js +356 -0
- package/dist/tools/uiHide.js.map +1 -0
- package/dist/tools/uiPage.js +861 -0
- package/dist/tools/uiPage.js.map +1 -0
- package/dist/tools/uiWin.js +506 -0
- package/dist/tools/uiWin.js.map +1 -0
- package/dist/tools/webFetch.js +32 -25
- package/dist/tools/webFetch.js.map +1 -1
- package/dist/tools/webSearch.js +12 -0
- package/dist/tools/webSearch.js.map +1 -1
- package/package.json +78 -78
package/dist/cli/App.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
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
|
|
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,
|
|
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 {
|
|
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 {
|
|
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
|
-
"/
|
|
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
|
-
|
|
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
|
-
|
|
606
|
-
|
|
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
|
|
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
|
-
//
|
|
845
|
-
// the
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1634
|
-
//
|
|
1635
|
-
|
|
1636
|
-
|
|
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
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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
|
|
1673
|
-
//
|
|
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
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
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
|
-
|
|
1718
|
-
|
|
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
|
|
1734
|
-
//
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
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
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 === "
|
|
2277
|
-
|
|
2278
|
-
|
|
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
|
|
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
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
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),
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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 === "
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
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 =
|
|
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
|
-
//
|
|
3339
|
-
//
|
|
3340
|
-
//
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
3734
|
-
|
|
3735
|
-
const
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
const
|
|
3743
|
-
return (_jsxs(Box, { flexDirection: "column",
|
|
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(
|
|
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(
|
|
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.
|
|
3891
|
-
// as jumping
|
|
3892
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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) {
|