march-cli 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "march-cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "March CLI — terminal-native coding agent with context reconstruction",
5
5
  "type": "module",
6
6
  "main": "./src/main.mjs",
@@ -19,6 +19,7 @@
19
19
  "test:shell-tui-real": "node test/shell-tui-real.acceptance.mjs",
20
20
  "test:tui-key-real": "node test/tui-key-real.acceptance.mjs",
21
21
  "context": "cd .. && node march-cli/bin/march.mjs --dump-context",
22
+ "notify:experiment": "node scripts/notify-experiment.mjs",
22
23
  "publish:env": "node scripts/npm-publish-from-env.mjs"
23
24
  },
24
25
  "dependencies": {
@@ -28,6 +29,7 @@
28
29
  "@modelcontextprotocol/sdk": "^1.29.0",
29
30
  "@xterm/headless": "^5.5.0",
30
31
  "marked": "^18.0.3",
32
+ "node-notifier": "^10.0.1",
31
33
  "node-pty": "^1.1.0",
32
34
  "typebox": "^1.0.58",
33
35
  "web-tree-sitter": "^0.26.8"
@@ -9,7 +9,7 @@ import { createMarchLifecycleAdapter } from "../extensions/lifecycle-adapter.mjs
9
9
  import { syncPiSessionSidecar } from "../session/sidecar-sync.mjs";
10
10
  import { LspService } from "../lsp/service.mjs";
11
11
  import { formatRecallHints } from "../memory/markdown-store.mjs";
12
- import { appendProviderUserMessage, installModelPayloadDumper, replaceProviderContextMessages } from "./model-payload-dumper.mjs";
12
+ import { appendProviderUserMessage, estimateProviderPayloadTokens, installModelPayloadDumper, replaceProviderContextMessages } from "./model-payload-dumper.mjs";
13
13
  import { resolveInitialModel, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
14
14
  import { runRunnerCleanup } from "./runner/runner-cleanup.mjs";
15
15
  import { createRunnerRuntimeHost } from "./runtime/runner-runtime-host.mjs";
@@ -18,7 +18,7 @@ import { resolveRunnerSessionOptions } from "./session/session-options.mjs";
18
18
  import { createSessionBinding } from "./session/session-binding.mjs";
19
19
  import { maybeAutoNameSession } from "./session/session-auto-name.mjs";
20
20
  import { MARCH_BASE_TOOL_NAMES } from "./tool-names.mjs";
21
- import { runRunnerTurn } from "./turn/turn-runner.mjs";
21
+ import { isModelStreamIdleTimeoutError, runRunnerTurn } from "./turn/turn-runner.mjs";
22
22
  import { appendFastVariants, createFastModelEntry, fromFastEntryModel, isFastProvider } from "./runner/fast-model.mjs";
23
23
  import { registerSuperGrokProvider } from "../supergrok/provider.mjs";
24
24
 
@@ -27,7 +27,7 @@ export { installModelPayloadDumper } from "./model-payload-dumper.mjs";
27
27
  export { createDefaultSessionManager, resolveRunnerSessionManager } from "./runner/runner-init.mjs";
28
28
  export { getRunnerSessionStats, syncEngineSessionState } from "./runner/runner-session-state.mjs";
29
29
 
30
- export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, memoryStore = null, memoryTools = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null }) {
30
+ export async function createRunner({ cwd, modelId = null, provider = null, providers = {}, stateRoot, ui, memoryRoot = null, centerMemoryPath = null, memoryStore = null, memoryTools = [], shellRuntime = null, mcpTools = [], mcpInjections = [], mcpClientManager = null, webTools = [], namespace = "", sessionManager = null, useRuntimeHost = false, projectMarchDir = null, syncPiSidecar = false, extensionPaths = [], lifecycleHooks = [], lifecycleDiagnostics = [], authStorage = null, permissionController = null, modelContextDumper = null, turnNotifier = null, onModelPayload = null, createAgentSessionImpl = createAgentSession, createAgentSessionRuntimeImpl, createRuntimeServices, createRuntimeSessionFromServices, maxTurns, trimBatch, serviceTier = null, modelStreamIdleTimeoutMs = 7000, modelStreamIdleMaxRetries = 5 }) {
31
31
  if (!useRuntimeHost && extensionPaths.length > 0) {
32
32
  throw new Error("--extension requires the default pi runtime host path");
33
33
  }
@@ -47,7 +47,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
47
47
  retry: { enabled: true, maxRetries: 3, baseDelayMs: 2000 },
48
48
  });
49
49
  const lspService = new LspService({ cwd });
50
- const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
50
+ const engine = new ContextEngine({ cwd, modelId, provider, namespace, memoryRoot, centerMemoryPath, shellRuntime, lspService, injections: mcpInjections, maxTurns, trimBatch });
51
51
  const resolvedSessionManager = resolveRunnerSessionManager(cwd, sessionManager);
52
52
  const sessionBinding = createSessionBinding(null);
53
53
  let currentModelCallKind = "model";
@@ -55,6 +55,7 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
55
55
  let currentTurnContextMode = "rebuild";
56
56
  let nextTurnContextMode = "rebuild";
57
57
  let pendingMidTurnRecallHints = [];
58
+ let lastNotificationResult = null;
58
59
  let runtimeHost = null;
59
60
  let lifecycleAdapter = null;
60
61
  let _currentFastEntry = null;
@@ -109,9 +110,10 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
109
110
  currentTurnContextMode = contextMode;
110
111
  nextTurnContextMode = "rebuild";
111
112
  pendingMidTurnRecallHints = [];
113
+ const turnStartedAt = Date.now();
112
114
  try {
113
115
  const result = await runRunnerTurn({
114
- prompt, userMessage, options: { userRecallHints, currentProject },
116
+ prompt, userMessage, options: { userRecallHints, currentProject, modelStreamIdleTimeoutMs, modelStreamIdleMaxRetries },
115
117
  sessionBinding, engine, ui, projectMarchDir, memoryStore,
116
118
  setModelCallKind: (kind) => { currentModelCallKind = kind; },
117
119
  onMidTurnRecallHints: (hints) => { pendingMidTurnRecallHints.push(...hints); },
@@ -119,17 +121,22 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
119
121
  autoNameSession,
120
122
  contextMode,
121
123
  });
122
- await notifyTurnEndBestEffort(turnNotifier, {
124
+ lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
123
125
  status: "success",
124
126
  sessionName: engine.sessionName,
125
127
  draft: result?.draft ?? "",
128
+ durationMs: Date.now() - turnStartedAt,
126
129
  });
127
130
  return result;
128
131
  } catch (err) {
129
- await notifyTurnEndBestEffort(turnNotifier, {
132
+ if (isModelStreamIdleTimeoutError(err)) {
133
+ nextTurnContextMode = "continueExistingPiTranscript";
134
+ }
135
+ lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
130
136
  status: "error",
131
137
  sessionName: engine.sessionName,
132
138
  errorMessage: err?.message ?? String(err),
139
+ durationMs: Date.now() - turnStartedAt,
133
140
  });
134
141
  throw err;
135
142
  } finally {
@@ -176,6 +183,20 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
176
183
  return [...new Set([...configured, ...available])];
177
184
  },
178
185
  getSessionStats() { return getRunnerSessionStats(sessionBinding.get(), runtimeHost); },
186
+ getLastNotificationResult() { return lastNotificationResult; },
187
+ async notifyTest({ title = "March", message = "If you see this, March runtime notifications work." } = {}) {
188
+ lastNotificationResult = await notifyTurnEndBestEffort(turnNotifier, {
189
+ status: "success",
190
+ sessionName: engine.sessionName,
191
+ title,
192
+ message,
193
+ durationMs: 0,
194
+ });
195
+ return lastNotificationResult;
196
+ },
197
+ estimateContextTokens(userMessage = "") {
198
+ return estimateProviderPayloadTokens(providerContextToPayload(engine.buildProviderContext(userMessage)));
199
+ },
179
200
  setSessionName(name) {
180
201
  const activeSession = sessionBinding.get();
181
202
  activeSession.setSessionName?.(name);
@@ -256,11 +277,21 @@ export async function createRunner({ cwd, modelId = null, provider = null, provi
256
277
  }
257
278
  }
258
279
 
280
+ function providerContextToPayload(providerContext) {
281
+ return {
282
+ messages: [
283
+ { role: "system", content: providerContext.system },
284
+ ...(providerContext.userMessages ?? []).map((message) => ({ role: "user", content: message.content })),
285
+ ],
286
+ };
287
+ }
288
+
259
289
  async function notifyTurnEndBestEffort(turnNotifier, event) {
260
- if (!turnNotifier?.notifyTurnEnd) return;
290
+ if (!turnNotifier?.notifyTurnEnd) return { ok: false, reason: "not-configured", results: [] };
261
291
  try {
262
- await turnNotifier.notifyTurnEnd(event);
263
- } catch {
292
+ return await turnNotifier.notifyTurnEnd(event);
293
+ } catch (err) {
264
294
  // Notification must never change turn behavior.
295
+ return { ok: false, reason: err?.message ?? String(err), results: [] };
265
296
  }
266
297
  }
@@ -1,6 +1,10 @@
1
1
  import { resolveImageAttachmentReferences } from "../../session/attachment-references.mjs";
2
2
  import { closeAssistantReply, createTurnEventState, handleRunnerSessionEvent } from "./turn-events.mjs";
3
3
 
4
+ export function isModelStreamIdleTimeoutError(err) {
5
+ return err?.code === "MODEL_STREAM_IDLE_TIMEOUT";
6
+ }
7
+
4
8
  export async function runRunnerTurn({
5
9
  prompt,
6
10
  userMessage,
@@ -16,19 +20,31 @@ export async function runRunnerTurn({
16
20
  autoNameSession,
17
21
  contextMode = "rebuild",
18
22
  }) {
19
- const { userRecallHints = [], currentProject = "" } = options;
23
+ const {
24
+ userRecallHints = [],
25
+ currentProject = "",
26
+ modelStreamIdleTimeoutMs = 7000,
27
+ modelStreamIdleMaxRetries = 5,
28
+ } = options;
20
29
  const activeSession = sessionBinding.get();
21
30
  const turnState = createTurnEventState();
22
31
  const midTurnRecallHints = [];
32
+ const modelIdleWatchdog = createModelStreamIdleWatchdog({
33
+ session: activeSession,
34
+ ui,
35
+ timeoutMs: modelStreamIdleTimeoutMs,
36
+ });
23
37
  ui.turnStart();
24
38
 
25
39
  const unsubscribe = activeSession.subscribe((event) => {
40
+ modelIdleWatchdog.handleEvent(event);
26
41
  handleRunnerSessionEvent(event, { ui, engine, state: turnState });
27
42
  if (event.type === "tool_execution_start") {
28
43
  const hints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
29
44
  if (hints.length > 0) {
30
45
  midTurnRecallHints.push(...hints);
31
46
  onMidTurnRecallHints?.(hints);
47
+ ui.memoryHint?.({ source: "assistant", hints });
32
48
  }
33
49
  }
34
50
  });
@@ -40,29 +56,48 @@ export async function runRunnerTurn({
40
56
  });
41
57
  setModelCallKind("user");
42
58
  try {
43
- if (contextMode === "rebuild") resetPiMessageHistory(activeSession);
44
- await activeSession.prompt(
45
- contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
46
- attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
47
- );
59
+ await promptWithModelIdleRetry({
60
+ session: activeSession,
61
+ prompt: contextMode === "continueExistingPiTranscript" ? (userMessage ?? prompt) : prompt,
62
+ promptOptions: attachmentReferences.images.length > 0 ? { images: attachmentReferences.images } : undefined,
63
+ resetBeforeAttempt: contextMode === "rebuild",
64
+ maxRetries: modelStreamIdleMaxRetries,
65
+ watchdog: modelIdleWatchdog,
66
+ });
67
+ } catch (err) {
68
+ if (!isModelStreamIdleTimeoutError(err)) throw err;
69
+ finalizeTurn({
70
+ prompt,
71
+ userMessage,
72
+ userRecallHints,
73
+ currentProject,
74
+ memoryStore,
75
+ engine,
76
+ ui,
77
+ turnState,
78
+ midTurnRecallHints,
79
+ syncCurrentPiSidecar,
80
+ autoNameSession,
81
+ });
82
+ throw err;
48
83
  } finally {
84
+ modelIdleWatchdog.stop();
49
85
  setModelCallKind("model");
50
86
  }
51
87
 
52
- closeAssistantReply({ ui, state: turnState });
53
- const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
54
- const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
55
- ui.memoryHint?.({ source: "assistant", hints: recordedAssistantRecallHints });
56
-
57
- engine.recordTurn({
58
- userMessage: userMessage ?? prompt.slice(0, 300),
59
- assistantMessage: turnState.draft,
88
+ finalizeTurn({
89
+ prompt,
90
+ userMessage,
60
91
  userRecallHints,
61
- assistantRecallHints: recordedAssistantRecallHints,
92
+ currentProject,
93
+ memoryStore,
94
+ engine,
95
+ ui,
96
+ turnState,
97
+ midTurnRecallHints,
98
+ syncCurrentPiSidecar,
99
+ autoNameSession,
62
100
  });
63
-
64
- autoNameSession?.();
65
- syncCurrentPiSidecar();
66
101
  return { draft: turnState.draft };
67
102
  } finally {
68
103
  ui.turnEnd();
@@ -70,6 +105,144 @@ export async function runRunnerTurn({
70
105
  }
71
106
  }
72
107
 
108
+ async function promptWithModelIdleRetry({ session, prompt, promptOptions, resetBeforeAttempt, maxRetries, watchdog }) {
109
+ let attempt = 0;
110
+ while (true) {
111
+ attempt += 1;
112
+ if (resetBeforeAttempt) resetPiMessageHistory(session);
113
+ const messageHistorySnapshot = clonePiMessageHistory(session);
114
+ watchdog.startAttempt({ attempt, maxAttempts: maxRetries + 1 });
115
+ let idleTimedOut = false;
116
+ try {
117
+ await session.prompt(prompt, promptOptions);
118
+ idleTimedOut = watchdog.consumeTimedOut();
119
+ if (!idleTimedOut) {
120
+ if (attempt > 1) watchdog.reportRecovered({ attempt: attempt - 1 });
121
+ return;
122
+ }
123
+ } catch (err) {
124
+ idleTimedOut = watchdog.consumeTimedOut();
125
+ if (!idleTimedOut) throw err;
126
+ } finally {
127
+ watchdog.stop();
128
+ }
129
+
130
+ if (attempt > maxRetries) {
131
+ const err = createModelStreamIdleTimeoutError(watchdog.timeoutMs);
132
+ watchdog.reportExhausted({ attempt, error: err });
133
+ throw err;
134
+ }
135
+ restorePiMessageHistory(session, messageHistorySnapshot);
136
+ watchdog.reportRetry({ attempt, maxAttempts: maxRetries + 1 });
137
+ }
138
+ }
139
+
140
+ function finalizeTurn({ prompt, userMessage, userRecallHints, currentProject, memoryStore, engine, ui, turnState, midTurnRecallHints, syncCurrentPiSidecar, autoNameSession }) {
141
+ closeAssistantReply({ ui, state: turnState });
142
+ const assistantRecallHints = flushAssistantRecall({ memoryStore, engine, turnState, currentProject });
143
+ engine.setPendingAssistantRecallHints?.(assistantRecallHints);
144
+ const recordedAssistantRecallHints = uniqueHints([...midTurnRecallHints, ...assistantRecallHints]);
145
+
146
+ engine.recordTurn({
147
+ userMessage: userMessage ?? prompt.slice(0, 300),
148
+ assistantMessage: turnState.draft,
149
+ userRecallHints,
150
+ assistantRecallHints: recordedAssistantRecallHints,
151
+ });
152
+
153
+ autoNameSession?.();
154
+ syncCurrentPiSidecar();
155
+ }
156
+
157
+ function createModelStreamIdleTimeoutError(timeoutMs) {
158
+ const err = new Error(`Model stream idle for ${timeoutMs}ms`);
159
+ err.code = "MODEL_STREAM_IDLE_TIMEOUT";
160
+ return err;
161
+ }
162
+
163
+ function createModelStreamIdleWatchdog({ session, ui, timeoutMs }) {
164
+ let timer = null;
165
+ let active = false;
166
+ let inTool = false;
167
+ let timedOut = false;
168
+
169
+ return {
170
+ timeoutMs,
171
+ startAttempt() {
172
+ timedOut = false;
173
+ inTool = false;
174
+ active = true;
175
+ arm();
176
+ },
177
+ handleEvent(event) {
178
+ if (!active || timedOut) return;
179
+ if (event.type === "tool_execution_start") {
180
+ inTool = true;
181
+ clear();
182
+ return;
183
+ }
184
+ if (event.type === "tool_execution_end") {
185
+ inTool = false;
186
+ arm();
187
+ return;
188
+ }
189
+ if (event.type === "message_update") arm();
190
+ },
191
+ stop() {
192
+ active = false;
193
+ clear();
194
+ },
195
+ consumeTimedOut() {
196
+ const value = timedOut;
197
+ timedOut = false;
198
+ return value;
199
+ },
200
+ reportRetry({ attempt, maxAttempts }) {
201
+ ui.retryStart?.({
202
+ attempt,
203
+ maxAttempts,
204
+ delayMs: 0,
205
+ errorMessage: `Model stream idle for ${timeoutMs}ms; retrying request`,
206
+ });
207
+ },
208
+ reportRecovered({ attempt }) {
209
+ ui.retryEnd?.({ success: true, attempt });
210
+ },
211
+ reportExhausted({ attempt, error }) {
212
+ ui.retryEnd?.({ success: false, attempt, finalError: error.message });
213
+ },
214
+ };
215
+
216
+ function arm() {
217
+ clear();
218
+ if (!active || inTool || timeoutMs <= 0) return;
219
+ timer = setTimeout(() => {
220
+ timedOut = true;
221
+ active = false;
222
+ clear();
223
+ session.abortRetry?.();
224
+ session.abort();
225
+ }, timeoutMs);
226
+ }
227
+
228
+ function clear() {
229
+ if (!timer) return;
230
+ clearTimeout(timer);
231
+ timer = null;
232
+ }
233
+ }
234
+
235
+ function clonePiMessageHistory(session) {
236
+ const messages = session?.agent?.state?.messages;
237
+ if (!Array.isArray(messages)) return null;
238
+ return JSON.parse(JSON.stringify(messages));
239
+ }
240
+
241
+ function restorePiMessageHistory(session, messages) {
242
+ if (!Array.isArray(session?.agent?.state?.messages) || !Array.isArray(messages)) return;
243
+ session.agent.state.messages = JSON.parse(JSON.stringify(messages));
244
+ }
245
+
73
246
  function flushAssistantRecall({ memoryStore, engine, turnState, currentProject }) {
74
247
  if (!memoryStore) return [];
75
248
  const text = assistantRecallDeltaText(turnState);
Binary file
@@ -18,16 +18,21 @@ export async function runSingleShotPrompt({
18
18
  modeState = null,
19
19
  }) {
20
20
  memoryStore.beginTurn();
21
+ const carryoverAlreadyRendered = runner.engine.hasRenderedPendingAssistantRecallHints?.() ?? false;
22
+ const carryoverRecallHints = runner.engine.takePendingAssistantRecallHints?.() ?? [];
21
23
  const userRecallHints = memoryStore.recallForUser(prompt, { currentProject, excludedIds: runner.engine.getRecentRecallMemoryIds?.() ?? [] });
22
24
  const recallBlock = formatRecallHints("user", userRecallHints);
25
+ const carryoverRecallBlock = formatRecallHints("assistant", carryoverRecallHints);
23
26
  const shellHints = formatShellHints(runner.shellRuntime);
24
27
  const modePrompt = appendModeReminder(prompt, modeState?.get?.());
25
- const fullPrompt = appendPromptBlocks(modePrompt, recallBlock, shellHints);
28
+ const fullPrompt = appendPromptBlocks(modePrompt, recallBlock, carryoverRecallBlock, shellHints);
26
29
  ui.writeln(formatUserDisplayMessage(prompt));
27
30
  ui.memoryHint?.({ source: "user", hints: userRecallHints });
31
+ if (carryoverRecallHints.length > 0 && !carryoverAlreadyRendered) ui.memoryHint?.({ source: "assistant", hints: carryoverRecallHints });
28
32
  refreshStatusBar.startWorking?.();
29
33
  try {
30
34
  await runner.runTurn(fullPrompt, prompt, { userRecallHints, currentProject });
35
+ renderPendingAssistantRecallPreview({ runner, ui });
31
36
  } finally {
32
37
  refreshStatusBar.stopWorking?.();
33
38
  memoryStore.endTurn();
@@ -87,7 +92,7 @@ export async function runInteractiveRepl({
87
92
  });
88
93
  if (slashResult.exit) break;
89
94
  if (slashResult.handled) {
90
- refreshStatusBar();
95
+ refreshStatusBar(contextTokenRefreshOptions(slashResult, runner));
91
96
  continue;
92
97
  }
93
98
 
@@ -111,6 +116,12 @@ export async function runInteractiveRepl({
111
116
  }
112
117
  }
113
118
 
119
+ export function contextTokenRefreshOptions(slashResult, runner) {
120
+ if (!slashResult?.refreshContextTokens) return undefined;
121
+ if (typeof runner.estimateContextTokens !== "function") return undefined;
122
+ return { contextTokens: runner.estimateContextTokens("") };
123
+ }
124
+
114
125
  function handleInlineCommand(trimmed, { cwd, ui, lastInlineShellCommand }) {
115
126
  const inlineShell = parseInlineShellInput(trimmed, lastInlineShellCommand);
116
127
  if (inlineShell.type === "error") {
@@ -126,17 +137,22 @@ function handleInlineCommand(trimmed, { cwd, ui, lastInlineShellCommand }) {
126
137
 
127
138
  async function runReplTurn({ prompt, args, runner, memoryStore, currentProject, ui, refreshStatusBar, setTurnRunning, modeState = null }) {
128
139
  memoryStore.beginTurn();
140
+ const carryoverAlreadyRendered = runner.engine.hasRenderedPendingAssistantRecallHints?.() ?? false;
141
+ const carryoverRecallHints = runner.engine.takePendingAssistantRecallHints?.() ?? [];
129
142
  const userRecallHints = memoryStore.recallForUser(prompt, { currentProject, excludedIds: runner.engine.getRecentRecallMemoryIds?.() ?? [] });
130
143
  const recallBlock = formatRecallHints("user", userRecallHints);
144
+ const carryoverRecallBlock = formatRecallHints("assistant", carryoverRecallHints);
131
145
  const shellHints = formatShellHints(runner.shellRuntime);
132
146
  const modePrompt = appendModeReminder(prompt, modeState?.get?.());
133
- const fullPrompt = appendPromptBlocks(modePrompt, recallBlock, shellHints);
147
+ const fullPrompt = appendPromptBlocks(modePrompt, recallBlock, carryoverRecallBlock, shellHints);
134
148
  try {
135
149
  ui.writeln(formatUserDisplayMessage(prompt));
136
150
  ui.memoryHint?.({ source: "user", hints: userRecallHints });
151
+ if (carryoverRecallHints.length > 0 && !carryoverAlreadyRendered) ui.memoryHint?.({ source: "assistant", hints: carryoverRecallHints });
137
152
  setTurnRunning(true);
138
153
  refreshStatusBar.startWorking?.();
139
154
  await runner.runTurn(fullPrompt, prompt, { userRecallHints, currentProject });
155
+ renderPendingAssistantRecallPreview({ runner, ui });
140
156
  ui.writeln("");
141
157
  } catch (err) {
142
158
  ui.writeln(`Error: ${err.message}`);
@@ -155,3 +171,11 @@ export function formatUserDisplayMessage(prompt) {
155
171
  function appendPromptBlocks(prompt, ...blocks) {
156
172
  return [prompt, ...blocks.filter(Boolean)].join("\n\n");
157
173
  }
174
+
175
+ function renderPendingAssistantRecallPreview({ runner, ui }) {
176
+ if (runner.engine.hasRenderedPendingAssistantRecallHints?.()) return;
177
+ const hints = runner.engine.peekPendingAssistantRecallHints?.() ?? [];
178
+ if (hints.length === 0) return;
179
+ ui.memoryHint?.({ source: "assistant", hints });
180
+ runner.engine.markPendingAssistantRecallHintsRendered?.();
181
+ }
@@ -40,11 +40,13 @@ export async function handleSlashCommand(trimmed, {
40
40
  ui.writeln("Error: pi runtime host is not enabled");
41
41
  return { handled: true };
42
42
  }
43
+ let refreshContextTokens = false;
43
44
  try {
44
45
  const result = await runner.startNewSession();
45
46
  if (result?.cancelled) {
46
47
  ui.writeln("New session cancelled");
47
48
  } else {
49
+ refreshContextTokens = true;
48
50
  ui.clearOutput?.();
49
51
  const bannerLines = typeof renderStartupBanner === "function" ? renderStartupBanner() : [];
50
52
  if (bannerLines.length > 0) {
@@ -56,7 +58,7 @@ export async function handleSlashCommand(trimmed, {
56
58
  } catch (err) {
57
59
  ui.writeln(`Error: ${err.message}`);
58
60
  }
59
- return { handled: true };
61
+ return { handled: true, refreshContextTokens };
60
62
  }
61
63
 
62
64
  if (trimmed === "/help") {
@@ -120,6 +122,12 @@ export async function handleSlashCommand(trimmed, {
120
122
  return { handled: true };
121
123
  }
122
124
 
125
+ if (trimmed === "/notify") {
126
+ const result = await runner.notifyTest?.();
127
+ ui.writeln(formatNotificationResult(result));
128
+ return { handled: true };
129
+ }
130
+
123
131
  const shellCommand = parseShellCommand(trimmed);
124
132
  if (shellCommand.type !== "none") {
125
133
  for (const line of handleShellCommand(shellCommand, { shellRuntime: runner.shellRuntime })) ui.writeln(line);
@@ -174,3 +182,11 @@ export async function handleSlashCommand(trimmed, {
174
182
 
175
183
  return { handled: false };
176
184
  }
185
+
186
+ function formatNotificationResult(result) {
187
+ if (!result) return "notification: unavailable";
188
+ const channels = (result.results ?? [])
189
+ .map((entry) => `${entry.channel}:${entry.ok ? "ok" : entry.reason ?? "failed"}`)
190
+ .join(", ");
191
+ return `notification: ${result.ok ? "ok" : result.reason ?? "failed"}${channels ? ` (${channels})` : ""}`;
192
+ }
@@ -1,6 +1,8 @@
1
1
  import { parseMouseEvent } from "./mouse-tracking.mjs";
2
2
  import { brightBlack } from "../ui-theme.mjs";
3
3
 
4
+ const OUTPUT_WHEEL_SCROLL_LINES = 3;
5
+
4
6
  export function createMouseSelectionController({
5
7
  terminal,
6
8
  output,
@@ -49,7 +51,7 @@ export function createMouseSelectionController({
49
51
  if (shellDrawer.isVisible?.() && mouse.col > Math.floor((terminal.columns || 80) * 0.64)) {
50
52
  shellDrawerControls.scroll(mouse.delta);
51
53
  } else {
52
- output.scroll(mouse.delta);
54
+ output.scroll(mouse.delta, { step: OUTPUT_WHEEL_SCROLL_LINES });
53
55
  }
54
56
  requestRender();
55
57
  return { consume: true };
@@ -167,6 +167,12 @@ function appendWrappedRuns(lines, runs, width, indent = 0, firstPrefix = null) {
167
167
  const maxWidth = Math.max(1, width);
168
168
  for (const run of runs) {
169
169
  for (const ch of run.text) {
170
+ if (ch === "\n") {
171
+ lines.push(current);
172
+ current = restPrefix;
173
+ currentWidth = visibleWidth(restPrefix);
174
+ continue;
175
+ }
170
176
  const charWidth = visibleWidth(ch);
171
177
  if (currentWidth + charWidth > maxWidth && currentWidth > visibleWidth(stripAnsi(prefix))) {
172
178
  lines.push(current);
@@ -17,11 +17,11 @@ export class OutputScrollState {
17
17
  this.totalLines = Math.max(0, Math.trunc(total));
18
18
  }
19
19
 
20
- scroll(delta) {
20
+ scroll(delta, { step = this.getStep() } = {}) {
21
21
  const total = this.totalLines;
22
22
  const win = this._windowHeight();
23
23
  const maxOffset = this.getMaxOffset();
24
- const step = this.getStep();
24
+ step = Math.max(1, Math.trunc(step));
25
25
  const currentStart = this.anchorStart ?? Math.max(0, total - this.offset - win);
26
26
  const maxStart = Math.max(0, total - win);
27
27
  const nextStart = clamp(currentStart + (delta < 0 ? -step : step), 0, maxStart);
@@ -0,0 +1,50 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import { R } from "../ui-theme.mjs";
3
+
4
+ export function appendTextLines(lines, textLines, width) {
5
+ for (const line of textLines) {
6
+ for (const part of String(line ?? "").split(/\r?\n/)) {
7
+ for (const wrapped of wrapLine(part, width)) lines.push(wrapped);
8
+ }
9
+ }
10
+ }
11
+
12
+ export function wrapLine(text, maxWidth) {
13
+ if (maxWidth <= 0) return [""];
14
+ const result = [];
15
+ let cur = "";
16
+ let curW = 0;
17
+ let activeSgr = "";
18
+ for (let i = 0; i < text.length;) {
19
+ if (text[i] === "\x1b") {
20
+ const match = text.slice(i).match(/^\x1b\[[0-?]*[ -/]*[@-~]/);
21
+ if (match) {
22
+ const seq = match[0];
23
+ cur += seq;
24
+ activeSgr = updateActiveSgr(activeSgr, seq);
25
+ i += seq.length;
26
+ continue;
27
+ }
28
+ }
29
+ const ch = text[i];
30
+ const w = visibleWidth(ch);
31
+ if (curW + w > maxWidth) {
32
+ result.push(activeSgr ? `${cur}${R}` : cur);
33
+ cur = activeSgr + ch;
34
+ curW = w;
35
+ } else {
36
+ cur += ch;
37
+ curW += w;
38
+ }
39
+ i += 1;
40
+ }
41
+ if (cur) result.push(cur);
42
+ return result.length > 0 ? result : [""];
43
+ }
44
+
45
+ function updateActiveSgr(activeSgr, seq) {
46
+ if (!seq.endsWith("m")) return activeSgr;
47
+ const body = seq.slice(2, -1);
48
+ if (body === "" || body.split(";").includes("0")) return "";
49
+ return seq;
50
+ }