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/BENCHMARK.md +408 -0
- package/PROGRESS.md +5 -0
- package/README.md +14 -1
- package/assets/mc-claude-smart.png +0 -0
- package/assets/mc-gpt-smart.png +0 -0
- package/benchmark-loop.sh +19 -0
- package/package.json +1 -1
- package/src/headless.ts +62 -21
- package/src/index.ts +35 -56
- package/src/prompt.ts +8 -0
- package/src/session-message.ts +393 -0
- package/src/session.ts +102 -396
- package/src/settings.ts +19 -15
- package/src/shared.ts +39 -0
- package/src/submit.ts +3 -25
- package/src/text.ts +71 -0
- package/src/tool-common.ts +91 -0
- package/src/tool-grep.ts +606 -0
- package/src/tool-read.ts +313 -0
- package/src/tool-shell.ts +869 -0
- package/src/tools.ts +186 -995
- package/src/ui/agent.ts +199 -110
- package/src/ui/commands.test.ts +14 -13
- package/src/ui/commands.ts +21 -47
- package/src/ui/conversation.test.ts +360 -0
- package/src/ui/conversation.ts +496 -151
- package/src/ui/input.test.ts +1 -43
- package/src/ui/runtime.ts +69 -0
- package/src/ui.ts +196 -114
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
|
|
5
|
-
*
|
|
6
|
-
* {@link AppState}; transient UI
|
|
7
|
-
* `ui.ts` can stay a small
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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
|
-
|
|
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.
|
|
272
|
+
runtime.requestRender("normal");
|
|
124
273
|
},
|
|
125
274
|
onTurnStart: () => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
pendingToolResults = [];
|
|
275
|
+
resetStreamingState();
|
|
276
|
+
streamingState.isStreaming = true;
|
|
129
277
|
runtime.startDividerAnimation();
|
|
130
|
-
runtime.
|
|
278
|
+
runtime.requestRender("normal");
|
|
131
279
|
},
|
|
132
280
|
onEvent: (event, currentState) => handleAgentEvent(event, currentState),
|
|
133
281
|
onTurnEnd: () => {
|
|
134
|
-
|
|
282
|
+
resetStreamingState();
|
|
135
283
|
runtime.stopDividerAnimation();
|
|
136
|
-
runtime.
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
}
|
package/src/ui/commands.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
+
requestRender: () => {},
|
|
425
426
|
reloadPromptContext: async () => {},
|
|
426
427
|
openInBrowser: () => {},
|
|
427
428
|
});
|
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
50
|
+
/** Open an overlay. */
|
|
49
51
|
openOverlay: (overlay: ActiveOverlay) => void;
|
|
50
|
-
/** Dismiss the active overlay
|
|
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
|
-
/**
|
|
68
|
-
|
|
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
|
|
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 =
|
|
168
|
+
const preview = truncateText(
|
|
187
169
|
formatPromptHistoryPreview(text),
|
|
188
170
|
HISTORY_PREVIEW_MAX_CHARS,
|
|
189
171
|
);
|
|
190
|
-
const displayCwd =
|
|
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 =
|
|
193
|
+
const preview = truncateText(
|
|
211
194
|
session.firstUserPreview ?? "No messages yet",
|
|
212
195
|
SESSION_PREVIEW_MAX_CHARS,
|
|
213
196
|
);
|
|
214
|
-
const model =
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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 (
|