mini-coder 0.5.11 → 0.5.12

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/src/ui/agent.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Input parsing, message submission, and streaming agent-event handling for the terminal UI.
3
3
  *
4
- * This module owns the transient streaming render state for the in-progress
5
- * assistant response. Persistent session and message state remain on
6
- * {@link AppState}; transient UI concerns are exposed through runtime hooks so
7
- * `ui.ts` can stay a small orchestrator.
4
+ * This module builds the controller used by `ui.ts`. Each controller owns its
5
+ * own transient streaming render state for the in-progress assistant response.
6
+ * Persistent session and message state remain on {@link AppState}; transient UI
7
+ * concerns are exposed through runtime hooks so `ui.ts` can stay a small
8
+ * orchestrator.
8
9
  *
9
10
  * @module
10
11
  */
@@ -22,17 +23,18 @@ import type {
22
23
  PendingToolResult,
23
24
  StreamingConversationState,
24
25
  } from "./conversation.ts";
26
+ import type { UiRenderPriority } from "./runtime.ts";
25
27
 
26
28
  export { isEmptyUserContent, stripSkillFrontmatter } from "../submit.ts";
27
29
 
28
- /** Streaming assistant content for the current response. */
29
- let streamingContent: AssistantMessage["content"] = [];
30
-
31
- /** Whether a response is currently streaming. */
32
- let isStreaming = false;
33
-
34
- /** Tool results collected during the current streaming turn. */
35
- let pendingToolResults: PendingToolResult[] = [];
30
+ interface UiAgentRuntimeState {
31
+ /** Whether a response is currently streaming. */
32
+ isStreaming: boolean;
33
+ /** Streaming assistant content for the current response. */
34
+ content: AssistantMessage["content"];
35
+ /** Tool results collected during the current streaming turn. */
36
+ pendingToolResults: PendingToolResult[];
37
+ }
36
38
 
37
39
  /** Hooks implemented by `ui.ts` to bridge controller logic with runtime UI state. */
38
40
  interface UiAgentRuntime {
@@ -40,8 +42,8 @@ interface UiAgentRuntime {
40
42
  appendInfoMessage: (text: string, state: AppState) => void;
41
43
  /** Dispatch a parsed slash command. */
42
44
  handleCommand: (command: string, state: AppState) => boolean;
43
- /** Trigger a UI render. */
44
- render: () => void;
45
+ /** Schedule a UI render. */
46
+ requestRender: (priority?: UiRenderPriority) => void;
45
47
  /** Re-enable stick-to-bottom behavior for the conversation log. */
46
48
  scrollConversationToBottom: () => void;
47
49
  /** Start the active-turn divider animation. */
@@ -54,30 +56,10 @@ interface UiAgentRuntime {
54
56
  interface UiAgentController {
55
57
  /** Route raw user input through parseInput and dispatch accordingly. */
56
58
  handleInput: (raw: string, state: AppState) => void;
57
- }
58
-
59
- /**
60
- * Reset the transient streaming UI state owned by this module.
61
- *
62
- * Used by `resetUiState()` and tests to keep state isolated.
63
- */
64
- export function resetUiAgentState(): void {
65
- streamingContent = [];
66
- isStreaming = false;
67
- pendingToolResults = [];
68
- }
69
-
70
- /**
71
- * Read the current streaming tail state for conversation rendering.
72
- *
73
- * @returns The in-progress assistant content and pending tool results.
74
- */
75
- export function getStreamingConversationState(): StreamingConversationState {
76
- return {
77
- isStreaming,
78
- content: streamingContent,
79
- pendingToolResults,
80
- };
59
+ /** Read the current streaming tail state for conversation rendering. */
60
+ getStreamingConversationState: () => StreamingConversationState;
61
+ /** Reset the transient streaming state owned by this controller. */
62
+ reset: () => void;
81
63
  }
82
64
 
83
65
  /**
@@ -89,6 +71,173 @@ export function getStreamingConversationState(): StreamingConversationState {
89
71
  export function createUiAgentController(
90
72
  runtime: UiAgentRuntime,
91
73
  ): UiAgentController {
74
+ const streamingState: UiAgentRuntimeState = {
75
+ isStreaming: false,
76
+ content: [],
77
+ pendingToolResults: [],
78
+ };
79
+
80
+ const resetStreamingState = (): boolean => {
81
+ const changed =
82
+ streamingState.isStreaming ||
83
+ streamingState.content.length > 0 ||
84
+ streamingState.pendingToolResults.length > 0;
85
+ streamingState.isStreaming = false;
86
+ streamingState.content = [];
87
+ streamingState.pendingToolResults = [];
88
+ return changed;
89
+ };
90
+
91
+ const clearStreamingContent = (): boolean => {
92
+ if (streamingState.content.length === 0) {
93
+ return false;
94
+ }
95
+ streamingState.content = [];
96
+ return true;
97
+ };
98
+
99
+ const setStreamingContent = (
100
+ content: AssistantMessage["content"],
101
+ ): boolean => {
102
+ if (streamingState.content === content) {
103
+ return false;
104
+ }
105
+ streamingState.content = content;
106
+ return true;
107
+ };
108
+
109
+ const upsertPendingToolResult = (
110
+ event: Extract<AgentEvent, { type: "tool_delta" | "tool_end" }>,
111
+ ): boolean => {
112
+ const pending = streamingState.pendingToolResults.find(
113
+ (toolResult) => toolResult.toolCallId === event.toolCallId,
114
+ );
115
+ if (pending) {
116
+ if (
117
+ pending.toolName === event.name &&
118
+ pending.content === event.result.content &&
119
+ pending.isError === event.result.isError
120
+ ) {
121
+ return false;
122
+ }
123
+ pending.toolName = event.name;
124
+ pending.content = event.result.content;
125
+ pending.isError = event.result.isError;
126
+ return true;
127
+ }
128
+
129
+ streamingState.pendingToolResults.push({
130
+ toolCallId: event.toolCallId,
131
+ toolName: event.name,
132
+ content: event.result.content,
133
+ isError: event.result.isError,
134
+ });
135
+ return true;
136
+ };
137
+
138
+ const removePendingToolResult = (toolCallId: string): boolean => {
139
+ const nextPendingToolResults = streamingState.pendingToolResults.filter(
140
+ (toolResult) => toolResult.toolCallId !== toolCallId,
141
+ );
142
+ if (
143
+ nextPendingToolResults.length === streamingState.pendingToolResults.length
144
+ ) {
145
+ return false;
146
+ }
147
+ streamingState.pendingToolResults = nextPendingToolResults;
148
+ return true;
149
+ };
150
+
151
+ const isStreamingContentEvent = (
152
+ event: AgentEvent,
153
+ ): event is Extract<
154
+ AgentEvent,
155
+ {
156
+ type:
157
+ | "text_delta"
158
+ | "thinking_delta"
159
+ | "toolcall_start"
160
+ | "toolcall_delta"
161
+ | "toolcall_end";
162
+ }
163
+ > => {
164
+ return (
165
+ event.type === "text_delta" ||
166
+ event.type === "thinking_delta" ||
167
+ event.type === "toolcall_start" ||
168
+ event.type === "toolcall_delta" ||
169
+ event.type === "toolcall_end"
170
+ );
171
+ };
172
+
173
+ const isPendingToolProgressEvent = (
174
+ event: AgentEvent,
175
+ ): event is Extract<AgentEvent, { type: "tool_delta" | "tool_end" }> => {
176
+ return event.type === "tool_delta" || event.type === "tool_end";
177
+ };
178
+
179
+ const handleCommittedAgentEvent = (
180
+ event: Exclude<
181
+ AgentEvent,
182
+ | Extract<
183
+ AgentEvent,
184
+ {
185
+ type:
186
+ | "text_delta"
187
+ | "thinking_delta"
188
+ | "toolcall_start"
189
+ | "toolcall_delta"
190
+ | "toolcall_end";
191
+ }
192
+ >
193
+ | Extract<AgentEvent, { type: "tool_delta" | "tool_end" }>
194
+ >,
195
+ ): void => {
196
+ switch (event.type) {
197
+ case "user_message":
198
+ runtime.scrollConversationToBottom();
199
+ runtime.requestRender("normal");
200
+ return;
201
+ case "assistant_message":
202
+ if (clearStreamingContent()) {
203
+ runtime.requestRender("normal");
204
+ }
205
+ return;
206
+ case "tool_result":
207
+ if (removePendingToolResult(event.message.toolCallId)) {
208
+ runtime.requestRender("normal");
209
+ }
210
+ return;
211
+ case "done":
212
+ case "error":
213
+ case "aborted":
214
+ if (resetStreamingState()) {
215
+ runtime.requestRender("normal");
216
+ }
217
+ return;
218
+ case "tool_start":
219
+ return;
220
+ }
221
+ };
222
+
223
+ const handleAgentEvent = (event: AgentEvent, _state: AppState): void => {
224
+ if (isStreamingContentEvent(event)) {
225
+ if (setStreamingContent(event.content)) {
226
+ runtime.requestRender("stream");
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (isPendingToolProgressEvent(event)) {
232
+ if (upsertPendingToolResult(event)) {
233
+ runtime.requestRender("stream");
234
+ }
235
+ return;
236
+ }
237
+
238
+ handleCommittedAgentEvent(event);
239
+ };
240
+
92
241
  const submitMessageAsync = (rawInput: string, state: AppState): void => {
93
242
  const resolved = resolveRawInput(rawInput, state);
94
243
 
@@ -120,20 +269,19 @@ export function createUiAgentController(
120
269
  submitPromise = submitResolvedInput(rawInput, resolved.content, state, {
121
270
  onUserMessage: () => {
122
271
  runtime.scrollConversationToBottom();
123
- runtime.render();
272
+ runtime.requestRender("normal");
124
273
  },
125
274
  onTurnStart: () => {
126
- isStreaming = true;
127
- streamingContent = [];
128
- pendingToolResults = [];
275
+ resetStreamingState();
276
+ streamingState.isStreaming = true;
129
277
  runtime.startDividerAnimation();
130
- runtime.render();
278
+ runtime.requestRender("normal");
131
279
  },
132
280
  onEvent: (event, currentState) => handleAgentEvent(event, currentState),
133
281
  onTurnEnd: () => {
134
- resetUiAgentState();
282
+ resetStreamingState();
135
283
  runtime.stopDividerAnimation();
136
- runtime.render();
284
+ runtime.requestRender("normal");
137
285
  },
138
286
  })
139
287
  .then(() => undefined)
@@ -152,70 +300,11 @@ export function createUiAgentController(
152
300
  state.activeTurnPromise = submitPromise;
153
301
  };
154
302
 
155
- const handleAgentEvent = (event: AgentEvent, _state: AppState): void => {
156
- switch (event.type) {
157
- case "text_delta":
158
- case "thinking_delta":
159
- case "toolcall_start":
160
- case "toolcall_delta":
161
- case "toolcall_end":
162
- streamingContent = event.content;
163
- runtime.render();
164
- break;
165
-
166
- case "user_message":
167
- runtime.scrollConversationToBottom();
168
- runtime.render();
169
- break;
170
-
171
- case "assistant_message":
172
- streamingContent = [];
173
- runtime.render();
174
- break;
175
-
176
- case "tool_start":
177
- break;
178
-
179
- case "tool_delta":
180
- case "tool_end": {
181
- const pending = pendingToolResults.find(
182
- (toolResult) => toolResult.toolCallId === event.toolCallId,
183
- );
184
- if (pending) {
185
- pending.toolName = event.name;
186
- pending.content = event.result.content;
187
- pending.isError = event.result.isError;
188
- } else {
189
- pendingToolResults.push({
190
- toolCallId: event.toolCallId,
191
- toolName: event.name,
192
- content: event.result.content,
193
- isError: event.result.isError,
194
- });
195
- }
196
- runtime.render();
197
- break;
198
- }
199
-
200
- case "tool_result":
201
- pendingToolResults = pendingToolResults.filter(
202
- (toolResult) => toolResult.toolCallId !== event.message.toolCallId,
203
- );
204
- runtime.render();
205
- break;
206
-
207
- case "done":
208
- case "error":
209
- case "aborted":
210
- resetUiAgentState();
211
- runtime.render();
212
- break;
213
- }
214
- };
215
-
216
- const handleInput = (raw: string, state: AppState): void => {
217
- submitMessageAsync(raw, state);
303
+ return {
304
+ handleInput: (raw, state) => {
305
+ submitMessageAsync(raw, state);
306
+ },
307
+ getStreamingConversationState: () => streamingState,
308
+ reset: resetStreamingState,
218
309
  };
219
-
220
- return { handleInput };
221
310
  }
@@ -90,6 +90,7 @@ describe("ui/commands", () => {
90
90
  const state = createTestState();
91
91
  const runtimeState = { overlay: null as ActiveOverlay | null };
92
92
  let scrollCalls = 0;
93
+ let renderCalls = 0;
93
94
  const controller = createCommandController({
94
95
  openOverlay: (nextOverlay) => {
95
96
  runtimeState.overlay = nextOverlay;
@@ -103,7 +104,9 @@ describe("ui/commands", () => {
103
104
  scrollConversationToBottom: () => {
104
105
  scrollCalls += 1;
105
106
  },
106
- render: () => {},
107
+ requestRender: () => {
108
+ renderCalls += 1;
109
+ },
107
110
  reloadPromptContext: async () => {},
108
111
  openInBrowser: () => {},
109
112
  });
@@ -153,13 +156,12 @@ describe("ui/commands", () => {
153
156
  expect(controller.handleCommand("session", state)).toBe(true);
154
157
 
155
158
  const overlay = expectOverlay(runtimeState.overlay);
156
- expect(overlay.title).toBe("Resume a session");
157
-
158
159
  const selectNode = renderSelect(overlay);
159
160
  selectNode.props.onKeyPress?.("enter");
160
161
 
161
162
  expect(runtimeState.overlay).toBeNull();
162
163
  expect(scrollCalls).toBe(1);
164
+ expect(renderCalls).toBe(1);
163
165
  expect(state.session?.id).toBe(session.id);
164
166
  expect(state.messages.map((message) => message.role)).toEqual([
165
167
  "user",
@@ -187,7 +189,7 @@ describe("ui/commands", () => {
187
189
  appendInfoMessage: () => {},
188
190
  appendTodoMessage: () => {},
189
191
  scrollConversationToBottom: () => {},
190
- render: () => {},
192
+ requestRender: () => {},
191
193
  reloadPromptContext: async (nextState) => {
192
194
  reloadCount++;
193
195
  nextState.agentsMd = [
@@ -243,7 +245,7 @@ describe("ui/commands", () => {
243
245
  appendInfoMessage: () => {},
244
246
  appendTodoMessage: () => {},
245
247
  scrollConversationToBottom: () => {},
246
- render: () => {},
248
+ requestRender: () => {},
247
249
  reloadPromptContext: async () => {},
248
250
  openInBrowser: () => {},
249
251
  });
@@ -269,7 +271,7 @@ describe("ui/commands", () => {
269
271
  appendInfoMessage: () => {},
270
272
  appendTodoMessage: () => {},
271
273
  scrollConversationToBottom: () => {},
272
- render: () => {},
274
+ requestRender: () => {},
273
275
  reloadPromptContext: async () => {},
274
276
  openInBrowser: () => {},
275
277
  });
@@ -284,7 +286,7 @@ describe("ui/commands", () => {
284
286
  }
285
287
  });
286
288
 
287
- test("/help appends markdown help without creating a session", () => {
289
+ test("/help appends a markdown info message without creating a session", () => {
288
290
  const state = createTestState();
289
291
  const appended: Array<{
290
292
  text: string;
@@ -304,7 +306,7 @@ describe("ui/commands", () => {
304
306
  },
305
307
  appendTodoMessage: () => {},
306
308
  scrollConversationToBottom: () => {},
307
- render: () => {},
309
+ requestRender: () => {},
308
310
  reloadPromptContext: async () => {},
309
311
  openInBrowser: () => {},
310
312
  });
@@ -312,8 +314,7 @@ describe("ui/commands", () => {
312
314
  try {
313
315
  expect(controller.handleCommand("help", state)).toBe(true);
314
316
  expect(appended).toHaveLength(1);
315
- expect(appended[0]?.text).toContain("# Help");
316
- expect(appended[0]?.text).toContain("## Commands");
317
+ expect(appended[0]?.text.length).toBeGreaterThan(0);
317
318
  expect(appended[0]?.format).toBe("markdown");
318
319
  expect(appended[0]?.sessionId).toBeNull();
319
320
  expect(state.session).toBeNull();
@@ -360,7 +361,7 @@ describe("ui/commands", () => {
360
361
  });
361
362
  },
362
363
  scrollConversationToBottom: () => {},
363
- render: () => {},
364
+ requestRender: () => {},
364
365
  reloadPromptContext: async () => {},
365
366
  openInBrowser: () => {},
366
367
  });
@@ -393,7 +394,7 @@ describe("ui/commands", () => {
393
394
  appendInfoMessage: () => {},
394
395
  appendTodoMessage: () => {},
395
396
  scrollConversationToBottom: () => {},
396
- render: () => {},
397
+ requestRender: () => {},
397
398
  reloadPromptContext: async () => {},
398
399
  openInBrowser: () => {},
399
400
  });
@@ -421,7 +422,7 @@ describe("ui/commands", () => {
421
422
  appendInfoMessage: () => {},
422
423
  appendTodoMessage: () => {},
423
424
  scrollConversationToBottom: () => {},
424
- render: () => {},
425
+ requestRender: () => {},
425
426
  reloadPromptContext: async () => {},
426
427
  openInBrowser: () => {},
427
428
  });
@@ -19,20 +19,22 @@ import type { AppState } from "../index.ts";
19
19
  import { getAvailableModels, saveOAuthCredentials } from "../index.ts";
20
20
  import { COMMANDS } from "../input.ts";
21
21
  import {
22
- computeContextTokens,
23
- computeStats,
22
+ clearConversationState,
24
23
  forkSession,
25
24
  listPromptHistory,
26
25
  listSessions,
27
26
  loadMessages,
27
+ replaceConversationState,
28
28
  type SessionListEntry,
29
29
  type UiInfoFormat,
30
30
  undoLastTurn,
31
31
  } from "../session.ts";
32
32
  import { updateSettings } from "../settings.ts";
33
+ import { collapseWhitespace, truncateText } from "../text.ts";
33
34
  import { getTodoItems } from "../tools.ts";
34
35
  import { buildHelpText, COMMAND_DESCRIPTIONS } from "./help.ts";
35
36
  import { type ActiveOverlay, OVERLAY_MAX_VISIBLE } from "./overlay.ts";
37
+ import type { UiRenderPriority } from "./runtime.ts";
36
38
  import { abbreviatePath } from "./status.ts";
37
39
 
38
40
  /** Effort levels available for selection. */
@@ -45,9 +47,9 @@ const EFFORT_LEVELS: { label: string; value: ThinkingLevel }[] = [
45
47
 
46
48
  /** Runtime hooks injected from the stateful UI module. */
47
49
  interface UiCommandRuntime {
48
- /** Open an overlay and trigger a re-render. */
50
+ /** Open an overlay. */
49
51
  openOverlay: (overlay: ActiveOverlay) => void;
50
- /** Dismiss the active overlay and trigger a re-render. */
52
+ /** Dismiss the active overlay. */
51
53
  dismissOverlay: () => void;
52
54
  /** Update the current input draft. */
53
55
  setInputValue: (value: string) => void;
@@ -64,8 +66,8 @@ interface UiCommandRuntime {
64
66
  ) => void;
65
67
  /** Re-enable stick-to-bottom behavior for the conversation log. */
66
68
  scrollConversationToBottom: () => void;
67
- /** Trigger a UI re-render. */
68
- render: () => void;
69
+ /** Schedule a UI re-render. */
70
+ requestRender: (priority?: UiRenderPriority) => void;
69
71
  /** Reload prompt/session context at a boundary like `/new`. */
70
72
  reloadPromptContext: (state: AppState) => Promise<void>;
71
73
  /** Open a URL in the user's default browser. */
@@ -142,7 +144,7 @@ export function formatRelativeDate(date: Date, now = new Date()): string {
142
144
  * @returns A single-line preview string.
143
145
  */
144
146
  export function formatPromptHistoryPreview(text: string): string {
145
- return text.replace(/\s+/g, " ").trim();
147
+ return collapseWhitespace(text);
146
148
  }
147
149
 
148
150
  const HISTORY_PREVIEW_MAX_CHARS = 32;
@@ -150,26 +152,6 @@ const HISTORY_CWD_MAX_CHARS = 18;
150
152
  const SESSION_PREVIEW_MAX_CHARS = 27;
151
153
  const SESSION_MODEL_MAX_CHARS = 17;
152
154
 
153
- function truncateTrailingText(text: string, maxChars: number): string {
154
- if (text.length <= maxChars) {
155
- return text;
156
- }
157
- if (maxChars <= 1) {
158
- return "…";
159
- }
160
- return `${text.slice(0, maxChars - 1)}…`;
161
- }
162
-
163
- function truncateLeadingText(text: string, maxChars: number): string {
164
- if (text.length <= maxChars) {
165
- return text;
166
- }
167
- if (maxChars <= 1) {
168
- return "…";
169
- }
170
- return `…${text.slice(text.length - (maxChars - 1))}`;
171
- }
172
-
173
155
  /**
174
156
  * Format a prompt-history row for the Select overlay.
175
157
  *
@@ -183,13 +165,14 @@ export function formatPromptHistoryLabel(
183
165
  cwd: string,
184
166
  date: string,
185
167
  ): string {
186
- const preview = truncateTrailingText(
168
+ const preview = truncateText(
187
169
  formatPromptHistoryPreview(text),
188
170
  HISTORY_PREVIEW_MAX_CHARS,
189
171
  );
190
- const displayCwd = truncateLeadingText(
172
+ const displayCwd = truncateText(
191
173
  abbreviatePath(cwd),
192
174
  HISTORY_CWD_MAX_CHARS,
175
+ "start",
193
176
  );
194
177
  return `${preview} · ${displayCwd} · ${date}`;
195
178
  }
@@ -207,11 +190,11 @@ export function formatSessionLabel(
207
190
  date: string,
208
191
  isCurrent: boolean,
209
192
  ): string {
210
- const preview = truncateTrailingText(
193
+ const preview = truncateText(
211
194
  session.firstUserPreview ?? "No messages yet",
212
195
  SESSION_PREVIEW_MAX_CHARS,
213
196
  );
214
- const model = truncateTrailingText(
197
+ const model = truncateText(
215
198
  session.model ?? "no model",
216
199
  SESSION_MODEL_MAX_CHARS,
217
200
  );
@@ -397,13 +380,12 @@ export function createCommandController(
397
380
  const picked = sessions.find((session) => session.id === sessionId);
398
381
  if (picked) {
399
382
  state.session = picked;
400
- state.messages = loadMessages(state.db, picked.id);
401
- state.stats = computeStats(state.messages);
402
- state.contextTokens = computeContextTokens(state.messages);
383
+ replaceConversationState(state, loadMessages(state.db, picked.id));
403
384
  runtime.scrollConversationToBottom();
404
385
  }
405
386
  }
406
387
  runtime.dismissOverlay();
388
+ runtime.requestRender("normal");
407
389
  },
408
390
  );
409
391
  };
@@ -413,12 +395,10 @@ export function createCommandController(
413
395
  return;
414
396
  }
415
397
  state.session = null;
416
- state.messages = [];
417
- state.stats = { totalInput: 0, totalOutput: 0, totalCost: 0 };
418
- state.contextTokens = 0;
398
+ clearConversationState(state);
419
399
  await runtime.reloadPromptContext(state);
420
400
  runtime.scrollConversationToBottom();
421
- runtime.render();
401
+ runtime.requestRender("normal");
422
402
  };
423
403
 
424
404
  const handleForkCommand = (state: AppState): void => {
@@ -427,9 +407,7 @@ export function createCommandController(
427
407
  }
428
408
  const forked = forkSession(state.db, state.session.id);
429
409
  state.session = forked;
430
- state.messages = loadMessages(state.db, forked.id);
431
- state.stats = computeStats(state.messages);
432
- state.contextTokens = computeContextTokens(state.messages);
410
+ replaceConversationState(state, loadMessages(state.db, forked.id));
433
411
  runtime.appendInfoMessage("Forked session.", state);
434
412
  };
435
413
 
@@ -447,11 +425,9 @@ export function createCommandController(
447
425
  }
448
426
  const removed = undoLastTurn(state.db, state.session.id);
449
427
  if (removed) {
450
- state.messages = loadMessages(state.db, state.session.id);
451
- state.stats = computeStats(state.messages);
452
- state.contextTokens = computeContextTokens(state.messages);
428
+ replaceConversationState(state, loadMessages(state.db, state.session.id));
453
429
  runtime.scrollConversationToBottom();
454
- runtime.render();
430
+ runtime.requestRender("normal");
455
431
  }
456
432
  };
457
433
 
@@ -460,7 +436,6 @@ export function createCommandController(
460
436
  state.settings = updateSettings(state.settingsPath, {
461
437
  showReasoning: state.showReasoning,
462
438
  });
463
- runtime.render();
464
439
  };
465
440
 
466
441
  const handleVerboseCommand = (state: AppState): void => {
@@ -468,7 +443,6 @@ export function createCommandController(
468
443
  state.settings = updateSettings(state.settingsPath, {
469
444
  verbose: state.verbose,
470
445
  });
471
- runtime.render();
472
446
  };
473
447
 
474
448
  const performLogin = async (