min-agent 0.5.0 → 0.5.1

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.
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "async_hooks";
2
2
  import { readFileSync, mkdirSync, existsSync } from "fs";
3
3
  import path from "path";
4
4
  import { getActiveProvider, getConfigDir, getEffectiveConfig } from "./config.js";
5
+ import { fetchOllamaNativeChat, readFetchBody } from "./ollama-openai-bridge.js";
5
6
  import { atomicWriteFileSync } from "./tools/atomic-file.js";
6
7
  export const thinkingRequestStore = new AsyncLocalStorage();
7
8
  export const THINKING_WIRE_VERSION = 1;
@@ -74,6 +75,9 @@ export function thinkingWireCacheKey(host, modelId) {
74
75
  return `${h}::${m}`;
75
76
  }
76
77
  export function inferThinkingWire(modelId, host, providerType) {
78
+ if (providerType === "ollama") {
79
+ return { thinkingType: "omit", effort: "reasoning_effort" };
80
+ }
77
81
  const haystack = `${modelId} ${host}`.toLowerCase();
78
82
  if (haystack.includes("minimax")) {
79
83
  return { thinkingType: "adaptive-disabled", effort: "reasoning_effort" };
@@ -185,22 +189,29 @@ function profileUnchanged(current, next) {
185
189
  return current.thinkingType === next.thinkingType && current.effort === next.effort;
186
190
  }
187
191
  export async function fetchWithThinkingWire(inner, input, init, opts) {
188
- const spec = thinkingRequestStore.getStore();
189
- if (!spec || !init || typeof init.body !== "string")
192
+ if (!init?.body)
193
+ return inner(input, init);
194
+ const rawBody = await readFetchBody(init.body);
195
+ if (rawBody == null)
190
196
  return inner(input, init);
191
197
  let parsed;
192
198
  try {
193
- parsed = JSON.parse(init.body);
199
+ parsed = JSON.parse(rawBody);
194
200
  }
195
201
  catch {
196
202
  return inner(input, init);
197
203
  }
198
204
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
199
205
  return inner(input, init);
206
+ const spec = thinkingRequestStore.getStore();
207
+ const providerType = opts?.providerType ?? getActiveProvider(getEffectiveConfig())?.type;
200
208
  const body = parsed;
209
+ if (providerType === "ollama")
210
+ return fetchOllamaNativeChat(inner, input, init, body, spec);
211
+ if (!spec)
212
+ return inner(input, init);
201
213
  const host = hostnameFromInput(input);
202
214
  const modelId = typeof body.model === "string" ? body.model : "";
203
- const providerType = opts?.providerType ?? getActiveProvider(getEffectiveConfig())?.type;
204
215
  const profile = resolveThinkingWireProfile(host, modelId, providerType);
205
216
  const first = await inner(input, {
206
217
  ...init,
package/dist/thinking.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getEffectiveConfig, loadConfig, loadProjectConfig, saveConfig, saveProjectConfig, getActiveProvider, parseThinkingEffort, } from "./config.js";
2
- import { getCachedModelCatalog } from "./model-catalog.js";
2
+ import { getCachedModelCatalog, getModelCatalog } from "./model-catalog.js";
3
+ import { getCachedOllamaModel, getOllamaModel, thinkingChoicesFromOllama } from "./ollama-model.js";
3
4
  import { applyThinkingToChatBody, thinkingRequestStore } from "./thinking-wire.js";
4
5
  export { parseThinkingEffort, applyThinkingToChatBody, thinkingRequestStore };
5
6
  export const THINKING_EFFORTS = ["off", "low", "medium", "high", "max"];
@@ -36,6 +37,15 @@ export function thinkingChoicesFromReasoning(info) {
36
37
  return THINKING_EFFORTS.filter((effort) => set.has(effort));
37
38
  }
38
39
  export function thinkingChoicesForModel(modelId) {
40
+ const provider = getActiveProvider(getEffectiveConfig());
41
+ if (provider?.type === "ollama") {
42
+ if (!modelId)
43
+ return [...THINKING_EFFORTS];
44
+ const cached = getCachedOllamaModel(modelId, provider.baseURL);
45
+ if (!cached)
46
+ return [...THINKING_EFFORTS];
47
+ return thinkingChoicesFromOllama(cached);
48
+ }
39
49
  if (!modelId)
40
50
  return [...THINKING_EFFORTS];
41
51
  const entry = getCachedModelCatalog(modelId);
@@ -43,6 +53,18 @@ export function thinkingChoicesForModel(modelId) {
43
53
  return [...THINKING_EFFORTS];
44
54
  return thinkingChoicesFromReasoning(entry.reasoning);
45
55
  }
56
+ export async function refreshThinkingChoices(modelId, hint) {
57
+ const provider = getActiveProvider(getEffectiveConfig());
58
+ const type = hint?.type ?? provider?.type;
59
+ const baseURL = hint?.baseURL ?? provider?.baseURL;
60
+ if (modelId) {
61
+ if (type === "ollama")
62
+ await getOllamaModel(modelId, baseURL);
63
+ else
64
+ await getModelCatalog(modelId, baseURL);
65
+ }
66
+ return thinkingChoicesForModel(modelId);
67
+ }
46
68
  export function clampThinkingEffort(effort, choices = THINKING_EFFORTS) {
47
69
  if (choices.length === 0 || choices.includes(effort))
48
70
  return effort;
@@ -53,7 +75,9 @@ export function clampThinkingEffort(effort, choices = THINKING_EFFORTS) {
53
75
  const enabled = choices.filter((item) => item !== "off");
54
76
  if (enabled.length > 0)
55
77
  return enabled[Math.floor((enabled.length - 1) / 2)];
56
- return choices.find((item) => item !== "off") ?? DEFAULT_THINKING_EFFORT;
78
+ if (choices.includes("off"))
79
+ return "off";
80
+ return DEFAULT_THINKING_EFFORT;
57
81
  }
58
82
  export function thinkingSourceLabel(source) {
59
83
  if (source === "cli")
package/dist/tui/App.js CHANGED
@@ -9,11 +9,12 @@ import { QuestionBar, questionBarRows } from "./QuestionBar.js";
9
9
  import { StatusBar } from "./StatusBar.js";
10
10
  import { ModelPicker, modelPickerRows } from "./ModelPicker.js";
11
11
  import { ThinkPicker, thinkPickerRows } from "./ThinkPicker.js";
12
+ import { CtxPicker, ctxPickerRows } from "./CtxPicker.js";
12
13
  import { SessionPicker, sessionPickerRows } from "./SessionPicker.js";
13
14
  import { setCaretPosition } from "./caret.js";
14
15
  import { computeMessageMaxHeight, frameRows, INPUT_BAR_ROWS } from "./layout.js";
15
16
  import { isCtrlC } from "./input-history.js";
16
- export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onModelPick, onModelCancel, onThinkPick, onThinkCancel, onSessionPick, onSessionCancel, onToggleSelectionMode, onExitSelectionMode, onExit, onCopyNotice, onNotice, }) {
17
+ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onModelPick, onModelCancel, onThinkPick, onThinkCancel, onCtxPick, onCtxCancel, onSessionPick, onSessionCancel, onToggleSelectionMode, onExitSelectionMode, onExit, onCopyNotice, onNotice, }) {
17
18
  // Ink recalculates its own layout on resize without re-rendering React, so
18
19
  // track terminal size ourselves to keep heights/widths in sync.
19
20
  const { stdout } = useStdout();
@@ -43,6 +44,10 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
43
44
  onThinkCancel();
44
45
  return;
45
46
  }
47
+ if (initialState.ctxPicker) {
48
+ onCtxCancel();
49
+ return;
50
+ }
46
51
  if (initialState.sessionPicker) {
47
52
  onSessionCancel();
48
53
  return;
@@ -61,6 +66,7 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
61
66
  initialState.question ||
62
67
  initialState.modelPicker ||
63
68
  initialState.thinkPicker ||
69
+ initialState.ctxPicker ||
64
70
  initialState.sessionPicker)
65
71
  return;
66
72
  if (initialState.isRunning)
@@ -76,6 +82,7 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
76
82
  !initialState.question &&
77
83
  !initialState.modelPicker &&
78
84
  !initialState.thinkPicker &&
85
+ !initialState.ctxPicker &&
79
86
  !initialState.sessionPicker) {
80
87
  onExit();
81
88
  }
@@ -84,6 +91,7 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
84
91
  initialState.question ||
85
92
  initialState.modelPicker ||
86
93
  initialState.thinkPicker ||
94
+ initialState.ctxPicker ||
87
95
  initialState.sessionPicker)
88
96
  setCaretPosition(null);
89
97
  const columns = terminal.columns;
@@ -97,8 +105,9 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
97
105
  const questionActive = Boolean(initialState.question);
98
106
  const modelPickerActive = Boolean(initialState.modelPicker);
99
107
  const thinkPickerActive = Boolean(initialState.thinkPicker);
108
+ const ctxPickerActive = Boolean(initialState.ctxPicker);
100
109
  const sessionPickerActive = Boolean(initialState.sessionPicker);
101
- const overlayActive = confirmActive || questionActive || modelPickerActive || thinkPickerActive || sessionPickerActive;
110
+ const overlayActive = confirmActive || questionActive || modelPickerActive || thinkPickerActive || ctxPickerActive || sessionPickerActive;
102
111
  useEffect(() => {
103
112
  if (overlayActive)
104
113
  setInputBarRows(INPUT_BAR_ROWS);
@@ -111,16 +120,19 @@ export function App({ initialState, onSubmit, onConfirm, onQuestionAnswer, onMod
111
120
  ? modelPickerRows(layoutRows)
112
121
  : thinkPickerActive
113
122
  ? thinkPickerRows()
114
- : sessionPickerActive
115
- ? sessionPickerRows(layoutRows)
116
- : inputBarRows;
123
+ : ctxPickerActive
124
+ ? ctxPickerRows()
125
+ : sessionPickerActive
126
+ ? sessionPickerRows(layoutRows)
127
+ : inputBarRows;
117
128
  const spinnerVisible = initialState.isRunning &&
118
129
  !confirmActive &&
119
130
  !questionActive &&
120
131
  !modelPickerActive &&
121
132
  !thinkPickerActive &&
133
+ !ctxPickerActive &&
122
134
  !sessionPickerActive;
123
135
  const spinnerRows = spinnerVisible ? 1 : 0;
124
136
  const messageMaxHeight = computeMessageMaxHeight(layoutRows, footerRows, spinnerRows);
125
- return (_jsxs(Box, { flexDirection: "column", width: "100%", height: layoutRows || undefined, overflow: "hidden", children: [!modelPickerActive && (_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", width: "100%", children: _jsx(MessageList, { messages: initialState.messages, maxHeight: messageMaxHeight, columns: columns, onCopyNotice: onCopyNotice, selectionMode: Boolean(initialState.selectionMode), onExitSelectionMode: onExitSelectionMode, interactive: !confirmActive && !questionActive }) })), spinnerVisible && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, width: "100%", children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState, columns: columns }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm, columns: columns, maxRows: footerRows })) : initialState.question ? (_jsx(QuestionBar, { prompt: initialState.question.prompt, options: initialState.question.options, onAnswer: onQuestionAnswer, columns: columns, maxRows: footerRows })) : initialState.modelPicker ? (_jsx(ModelPicker, { currentModel: initialState.model, onSelect: onModelPick, onCancel: onModelCancel })) : initialState.thinkPicker ? (_jsx(ThinkPicker, { scope: initialState.thinkPicker.scope, modelId: initialState.model, onSelect: (effort) => onThinkPick(effort, initialState.thinkPicker.scope), onCancel: onThinkCancel })) : initialState.sessionPicker ? (_jsx(SessionPicker, { currentId: initialState.sessionId, onSelect: onSessionPick, onCancel: onSessionCancel })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning || Boolean(initialState.selectionMode), placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)", columns: columns, terminalRows: layoutRows, onSelectionMode: onToggleSelectionMode, onExit: onExit, onRowsChange: onInputBarRowsChange, onNotice: onNotice }))] }));
137
+ return (_jsxs(Box, { flexDirection: "column", width: "100%", height: layoutRows || undefined, overflow: "hidden", children: [!modelPickerActive && (_jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", width: "100%", children: _jsx(MessageList, { messages: initialState.messages, maxHeight: messageMaxHeight, columns: columns, onCopyNotice: onCopyNotice, selectionMode: Boolean(initialState.selectionMode), onExitSelectionMode: onExitSelectionMode, interactive: !confirmActive && !questionActive }) })), spinnerVisible && (_jsx(Box, { paddingLeft: 1, flexShrink: 0, width: "100%", children: _jsx(Spinner, { label: initialState.spinnerText || "思考中..." }) })), _jsx(StatusBar, { state: initialState, columns: columns }), initialState.confirmMessage ? (_jsx(ConfirmBar, { message: initialState.confirmMessage, onConfirm: onConfirm, columns: columns, maxRows: footerRows })) : initialState.question ? (_jsx(QuestionBar, { prompt: initialState.question.prompt, options: initialState.question.options, onAnswer: onQuestionAnswer, columns: columns, maxRows: footerRows })) : initialState.modelPicker ? (_jsx(ModelPicker, { currentModel: initialState.model, onSelect: onModelPick, onCancel: onModelCancel })) : initialState.thinkPicker ? (_jsx(ThinkPicker, { scope: initialState.thinkPicker.scope, modelId: initialState.model, onSelect: (effort) => onThinkPick(effort, initialState.thinkPicker.scope), onCancel: onThinkCancel })) : initialState.ctxPicker ? (_jsx(CtxPicker, { onSelect: onCtxPick, onCancel: onCtxCancel })) : initialState.sessionPicker ? (_jsx(SessionPicker, { currentId: initialState.sessionId, onSelect: onSessionPick, onCancel: onSessionCancel })) : (_jsx(InputBar, { onSubmit: onSubmit, disabled: initialState.isRunning || Boolean(initialState.selectionMode), placeholder: initialState.isRunning ? "按 Esc 取消运行..." : "输入消息... (Ctrl+J 换行)", columns: columns, terminalRows: layoutRows, onSelectionMode: onToggleSelectionMode, onExit: onExit, onRowsChange: onInputBarRowsChange, onNotice: onNotice }))] }));
126
138
  }
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Box, Text, useInput } from "ink";
4
+ import { getActiveProvider, loadConfig } from "../config.js";
5
+ import { CTX_CHOICES, ctxChoiceLabel, matchCtxLevel } from "../ctx.js";
6
+ import { formatTokenCount } from "../token-display.js";
7
+ import { shouldAcceptOverlayConfirm } from "./overlay-input.js";
8
+ import { theme } from "./theme.js";
9
+ export function ctxPickerRows() {
10
+ return 2 + 1 + 1 + CTX_CHOICES.length + 1;
11
+ }
12
+ function configuredTokens() {
13
+ const n = getActiveProvider(loadConfig())?.contextWindow;
14
+ if (typeof n === "number" && Number.isFinite(n) && n > 0)
15
+ return Math.floor(n);
16
+ return undefined;
17
+ }
18
+ function currentLabel() {
19
+ const tokens = configuredTokens();
20
+ if (tokens == null)
21
+ return ctxChoiceLabel("auto");
22
+ return matchCtxLevel(tokens) ?? formatTokenCount(tokens);
23
+ }
24
+ function markedChoice() {
25
+ const tokens = configuredTokens();
26
+ if (tokens == null)
27
+ return "auto";
28
+ return matchCtxLevel(tokens);
29
+ }
30
+ export function CtxPicker({ onSelect, onCancel }) {
31
+ const marked = markedChoice();
32
+ const [index, setIndex] = useState(() => {
33
+ const i = marked ? CTX_CHOICES.indexOf(marked) : -1;
34
+ return i >= 0 ? i : CTX_CHOICES.length - 1;
35
+ });
36
+ const armed = useRef(false);
37
+ const indexRef = useRef(index);
38
+ indexRef.current = index;
39
+ useEffect(() => {
40
+ const timer = setTimeout(() => {
41
+ armed.current = true;
42
+ }, 0);
43
+ return () => clearTimeout(timer);
44
+ }, []);
45
+ useInput((input, key) => {
46
+ if (key.escape) {
47
+ onCancel();
48
+ return;
49
+ }
50
+ if (key.downArrow) {
51
+ setIndex((i) => (i + 1) % CTX_CHOICES.length);
52
+ return;
53
+ }
54
+ if (key.upArrow) {
55
+ setIndex((i) => (i - 1 + CTX_CHOICES.length) % CTX_CHOICES.length);
56
+ return;
57
+ }
58
+ if (shouldAcceptOverlayConfirm(armed.current, key, input)) {
59
+ const picked = CTX_CHOICES[indexRef.current];
60
+ if (picked)
61
+ onSelect(picked);
62
+ }
63
+ });
64
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, width: "100%", borderStyle: "round", borderColor: theme.pickerBorder, paddingX: 1, children: [_jsx(Box, { children: _jsx(Text, { bold: true, color: theme.pickerBorder, children: "\u9009\u62E9\u4E0A\u4E0B\u6587\u7A97\u53E3" }) }), _jsx(Box, { children: _jsxs(Text, { color: theme.muted, children: ["\u5F53\u524D: ", currentLabel()] }) }), CTX_CHOICES.map((choice, i) => {
65
+ const active = i === index;
66
+ return (_jsxs(Box, { children: [_jsx(Text, { color: active ? theme.accent : theme.muted, children: active ? "❯ " : " " }), _jsx(Text, { bold: active, color: active ? theme.accent : undefined, children: ctxChoiceLabel(choice) }), choice === marked && _jsx(Text, { color: theme.success, children: " \u2190 \u5F53\u524D" })] }, choice));
67
+ }), _jsx(Box, { children: _jsx(Text, { color: theme.muted, dimColor: true, wrap: "truncate-end", children: "\u2191\u2193 \u9009\u62E9 \u00B7 Enter \u786E\u8BA4 \u00B7 Esc \u53D6\u6D88" }) })] }));
68
+ }
@@ -5,7 +5,8 @@ import { setCaretPosition } from "./caret.js";
5
5
  import { displayWidth } from "./text-width.js";
6
6
  import { visualRows, moveCaretHorizontal, moveCaretVertical, insertAt, backspaceAt, deleteAt, caretIndexFromClick, scanDeleteKeys, scanHomeEndKeys, moveToLineStart, moveToLineEnd, deleteToLineStart, deleteToLineEnd, deleteWordBefore, } from "./caret-pos.js";
7
7
  import { searchHistory, loadInputHistory, saveInputHistory, createHistory, pushInput, browseHistory, resetHistory, isCtrlC, clearInputOnCtrlC, EXIT_CTRL_C_WINDOW_MS, } from "./input-history.js";
8
- import { filterSlashCommands, getSlashArgOptions, applySlashArgCompletion, isKnownSlashCommandInput, slashMenuWindow, } from "./slash-commands.js";
8
+ import { filterSlashCommands, sessionSlashCommands, getSlashArgOptions, applySlashArgCompletion, isKnownSlashCommandInput, slashMenuWindow, } from "./slash-commands.js";
9
+ import { getActiveProvider, getEffectiveConfig } from "../config.js";
9
10
  import { MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js";
10
11
  import { scanSgrMouse } from "./mouse.js";
11
12
  import { TEXT_START_COLUMN, inputTextWidth, inputBarPaintRows, slashMenuMaxVisible, SLASH_COMMAND_MENU_CHROME, SLASH_ARG_MENU_CHROME, INPUT_BAR_ROWS, } from "./layout.js";
@@ -150,7 +151,8 @@ export function InputBar({ onSubmit, disabled, placeholder, columns, terminalRow
150
151
  }, []);
151
152
  // Slash command menu — closes once a space follows the command name so the
152
153
  // argument menu can take over immediately.
153
- const matches = useMemo(() => filterSlashCommands(value), [value]);
154
+ const providerType = getActiveProvider(getEffectiveConfig())?.type;
155
+ const matches = useMemo(() => filterSlashCommands(value, sessionSlashCommands(providerType)), [value, providerType]);
154
156
  const menuOpen = value.startsWith("/") && !/\s/.test(value.slice(1)) && !menuDismissed && matches.length > 0;
155
157
  const shownIndex = menuOpen ? Math.min(menuIndex, matches.length - 1) : 0;
156
158
  const selected = menuOpen ? matches[shownIndex] : undefined;
@@ -2,8 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  import { getActiveProvider, getEffectiveConfig } from "../config.js";
5
- import { getModelCatalog } from "../model-catalog.js";
6
- import { resolveThinkingEffort, thinkingChoiceIndex, thinkingChoicesForModel, thinkingChoicesFromReasoning, thinkingEffortLabel, thinkingSourceLabel, } from "../thinking.js";
5
+ import { resolveThinkingEffort, thinkingChoiceIndex, thinkingChoicesForModel, thinkingEffortLabel, thinkingSourceLabel, refreshThinkingChoices, } from "../thinking.js";
7
6
  import { shouldAcceptOverlayConfirm } from "./overlay-input.js";
8
7
  import { theme } from "./theme.js";
9
8
  export function thinkPickerRows() {
@@ -31,10 +30,9 @@ export function ThinkPicker({ scope, modelId, onSelect, onCancel }) {
31
30
  if (!id)
32
31
  return;
33
32
  let cancelled = false;
34
- void getModelCatalog(id, provider?.baseURL).then((entry) => {
35
- if (cancelled || !entry)
33
+ void refreshThinkingChoices(id, { type: provider?.type, baseURL: provider?.baseURL }).then((next) => {
34
+ if (cancelled || next.length === 0)
36
35
  return;
37
- const next = thinkingChoicesFromReasoning(entry.reasoning);
38
36
  setChoices(next);
39
37
  if (moved.current) {
40
38
  setIndex((i) => Math.min(i, Math.max(0, next.length - 1)));
@@ -45,7 +43,7 @@ export function ThinkPicker({ scope, modelId, onSelect, onCancel }) {
45
43
  return () => {
46
44
  cancelled = true;
47
45
  };
48
- }, [id, provider?.baseURL]);
46
+ }, [id, provider?.baseURL, provider?.type]);
49
47
  useInput((input, key) => {
50
48
  if (key.escape) {
51
49
  onCancel();
package/dist/tui/index.js CHANGED
@@ -28,7 +28,7 @@ export class TuiRenderer {
28
28
  this.callbacks = callbacks;
29
29
  }
30
30
  appElement() {
31
- return (_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onQuestionAnswer: this.callbacks.onQuestionAnswer, onModelPick: this.callbacks.onModelPick, onModelCancel: this.callbacks.onModelCancel, onThinkPick: this.callbacks.onThinkPick, onThinkCancel: this.callbacks.onThinkCancel, onSessionPick: this.callbacks.onSessionPick, onSessionCancel: this.callbacks.onSessionCancel, onToggleSelectionMode: this.callbacks.onToggleSelectionMode, onExitSelectionMode: this.callbacks.onExitSelectionMode, onExit: this.callbacks.onExit, onCopyNotice: (text) => this.showCopyNotice(text), onNotice: this.callbacks.onNotice }));
31
+ return (_jsx(App, { initialState: this.state, onSubmit: this.callbacks.onSubmit, onConfirm: this.callbacks.onConfirm, onQuestionAnswer: this.callbacks.onQuestionAnswer, onModelPick: this.callbacks.onModelPick, onModelCancel: this.callbacks.onModelCancel, onThinkPick: this.callbacks.onThinkPick, onThinkCancel: this.callbacks.onThinkCancel, onCtxPick: this.callbacks.onCtxPick, onCtxCancel: this.callbacks.onCtxCancel, onSessionPick: this.callbacks.onSessionPick, onSessionCancel: this.callbacks.onSessionCancel, onToggleSelectionMode: this.callbacks.onToggleSelectionMode, onExitSelectionMode: this.callbacks.onExitSelectionMode, onExit: this.callbacks.onExit, onCopyNotice: (text) => this.showCopyNotice(text), onNotice: this.callbacks.onNotice }));
32
32
  }
33
33
  start() {
34
34
  this.inkInstance = render(this.appElement(), { exitOnCtrlC: false, stdout: createCaretAwareStdout(process.stdout) });
@@ -197,6 +197,12 @@ export class TuiRenderer {
197
197
  hideThinkPicker() {
198
198
  this.update({ thinkPicker: undefined });
199
199
  }
200
+ showCtxPicker() {
201
+ this.update({ ctxPicker: true, selectionMode: false });
202
+ }
203
+ hideCtxPicker() {
204
+ this.update({ ctxPicker: undefined });
205
+ }
200
206
  showSessionPicker() {
201
207
  this.update({ sessionPicker: true, selectionMode: false });
202
208
  }
@@ -6,6 +6,7 @@ export const SLASH_COMMANDS = [
6
6
  { name: "provider", description: "查看/切换 Provider", argHint: "[name]" },
7
7
  { name: "memory", description: "查看/开关记忆,或保存一条", argHint: "[on|off|t] [--project]" },
8
8
  { name: "tokens", description: "显示上下文占用、累计用量与成本" },
9
+ { name: "ctx", description: "选择本地模型上下文窗口" },
9
10
  { name: "budget", description: "查看/设置预算上限", argHint: "[n] [--project]" },
10
11
  { name: "permission", description: "查看/设置确认模式", argHint: "[ask|accept-edits|allow-all] [--project]" },
11
12
  { name: "think", description: "选择思考强度", aliases: ["thinking"] },
@@ -35,6 +36,11 @@ export function filterSlashCommands(input, commands = SLASH_COMMANDS) {
35
36
  return commands;
36
37
  return commands.filter((c) => c.name.startsWith(q) || (c.aliases ?? []).some((a) => a.startsWith(q)));
37
38
  }
39
+ export function sessionSlashCommands(providerType, commands = SLASH_COMMANDS) {
40
+ if (providerType === "ollama")
41
+ return commands;
42
+ return commands.filter((c) => c.name !== "ctx");
43
+ }
38
44
  /** Keep `selectedIndex` on screen; the highlight stays on the last row once the list scrolls. */
39
45
  export function slashMenuWindow(items, selectedIndex, maxVisible) {
40
46
  const max = Number.isFinite(maxVisible) ? Math.max(1, Math.floor(maxVisible)) : Math.max(1, items.length);
@@ -294,7 +294,7 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
294
294
  const price = await getModelPrice(currentModel ?? "");
295
295
  const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, price);
296
296
  const occupancyNote = tracker.lastInputTokens > 0 ? "" : ",按对话内容估算";
297
- const windowNote = ctx.source === "fallback" ? ",未读到接口窗口,按 512k 默认" : "";
297
+ const windowNote = ctx.source === "fallback" ? `,未读到接口窗口,按 ${ctx.tokens} 默认` : "";
298
298
  sysMsg(tui, [
299
299
  `上下文: ${occupied} / ${ctx.tokens} tokens (${pct}%${occupancyNote}${windowNote})`,
300
300
  `累计: ${tracker.totalInputTokens} in / ${tracker.totalOutputTokens} out`,
@@ -383,6 +383,31 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
383
383
  sysMsg(tui, `✓ 思考强度已设为 ${thinkingEffortLabel(parsed)}(${resolved === "project" ? "项目" : "全局"})`);
384
384
  break;
385
385
  }
386
+ case "ctx": {
387
+ const { getActiveProvider, getEffectiveConfig } = await import("../config.js");
388
+ const { CTX_CHOICES, parseCtxChoice, setOllamaContextChoice, ctxChoiceLabel } = await import("../ctx.js");
389
+ if (getActiveProvider(getEffectiveConfig())?.type !== "ollama") {
390
+ sysMsg(tui, "窗口档位仅本地模型可用");
391
+ break;
392
+ }
393
+ if (rest.length === 0) {
394
+ tui.showCtxPicker();
395
+ break;
396
+ }
397
+ const parsed = parseCtxChoice(rest[0]);
398
+ if (!parsed || rest.length > 1) {
399
+ sysMsg(tui, `用法: /ctx [${CTX_CHOICES.join("|")}]`);
400
+ break;
401
+ }
402
+ const result = setOllamaContextChoice(parsed);
403
+ if (!result.ok) {
404
+ sysMsg(tui, "窗口档位仅本地模型可用");
405
+ break;
406
+ }
407
+ state.refreshTokenBar();
408
+ sysMsg(tui, `✓ 上下文窗口已设为${ctxChoiceLabel(parsed)}`);
409
+ break;
410
+ }
386
411
  case "mcp": {
387
412
  const { loadMcpConfig } = await import("../mcp.js");
388
413
  const { getMcpStatus } = await import("../mcp.js");
@@ -544,6 +569,7 @@ export async function handleSlashCommand(input, tui, messages, currentModel, tra
544
569
  " /provider [n] 查看/切换 Provider",
545
570
  " /memory 查看记忆开关与已保存内容(/memory on|off 开关;也可 /memory 文本 保存)",
546
571
  " /tokens 显示上下文占用、累计用量与成本",
572
+ " /ctx 选择本地模型上下文窗口(也可 /ctx 2k|4k|8k|12k|16k|32k|64k|128k|256k|auto)",
547
573
  " /budget [n] 查看/设置预算上限(--project 写入当前项目)",
548
574
  " /permission 查看/设置确认模式(ask|accept-edits|allow-all,--project 写入当前项目)",
549
575
  " /think 交互式选择思考强度(档位随当前模型;也可 /think off|low|medium|high|max)",
package/dist/tui-chat.js CHANGED
@@ -17,8 +17,8 @@ import { listSessions, loadSession, saveSession } from "./sessions.js";
17
17
  import { hydrateMessages, lastUserText } from "./tui/hydrate.js";
18
18
  import { createPromptQueue } from "./tui/prompt-queue.js";
19
19
  import { prepareSwitch, switchFailMessage, prepareNew, newFailMessage } from "./tui/session-switch.js";
20
- import { getModelCatalog } from "./model-catalog.js";
21
- import { setThinkingEffort, setThinkingOverride, thinkingChoicesForModel, thinkingEffortLabel } from "./thinking.js";
20
+ import { setThinkingEffort, setThinkingOverride, thinkingChoicesForModel, thinkingEffortLabel, refreshThinkingChoices, } from "./thinking.js";
21
+ import { CTX_CHOICES, ctxChoiceLabel, setOllamaContextChoice } from "./ctx.js";
22
22
  let confirmResolver = null;
23
23
  let questionResolver = null;
24
24
  /** Only one confirm/question overlay can be shown at a time; parallel tool calls queue up. */
@@ -28,6 +28,13 @@ function registerSessionCompletion() {
28
28
  setSlashArgProvider("sessions", provider);
29
29
  setSlashArgProvider("resume", provider);
30
30
  }
31
+ function registerCtxCompletion() {
32
+ setSlashArgProvider("ctx", (tokens) => {
33
+ if (tokens.length > 0)
34
+ return [];
35
+ return CTX_CHOICES.map((value) => (value === "auto" ? { value, description: "按模型探测" } : { value }));
36
+ });
37
+ }
31
38
  function registerThinkCompletion() {
32
39
  const provider = (tokens) => {
33
40
  const flags = [
@@ -144,6 +151,7 @@ export async function runTui(opts) {
144
151
  registerSkillCompletion();
145
152
  registerPermissionCompletion();
146
153
  registerThinkCompletion();
154
+ registerCtxCompletion();
147
155
  registerMemoryCompletion();
148
156
  registerSandboxCompletion();
149
157
  registerSessionCompletion();
@@ -156,7 +164,7 @@ export async function runTui(opts) {
156
164
  let currentModel = modelId ?? getActiveProvider(config)?.defaultModel;
157
165
  const currentProvider = providerName;
158
166
  if (currentModel)
159
- void getModelCatalog(currentModel, getActiveProvider(config)?.baseURL);
167
+ void refreshThinkingChoices(currentModel);
160
168
  const tracker = new TokenTracker();
161
169
  const taskState = emptyTaskState();
162
170
  let undoStack = createUndoStack();
@@ -205,6 +213,7 @@ export async function runTui(opts) {
205
213
  startNewSession,
206
214
  openSessionPicker,
207
215
  taskState,
216
+ refreshTokenBar,
208
217
  });
209
218
  if (result === "run" && runner) {
210
219
  await runner.run();
@@ -262,6 +271,19 @@ export async function runTui(opts) {
262
271
  onThinkCancel: () => {
263
272
  tui.hideThinkPicker();
264
273
  },
274
+ onCtxPick: (choice) => {
275
+ const result = setOllamaContextChoice(choice);
276
+ tui.hideCtxPicker();
277
+ if (!result.ok) {
278
+ sysMsg(tui, "窗口档位仅本地模型可用");
279
+ return;
280
+ }
281
+ void refreshTokenBar();
282
+ sysMsg(tui, `✓ 上下文窗口已设为${ctxChoiceLabel(choice)}`);
283
+ },
284
+ onCtxCancel: () => {
285
+ tui.hideCtxPicker();
286
+ },
265
287
  onSessionPick: (id) => {
266
288
  tui.hideSessionPicker();
267
289
  applySession(id);
package/docs/API.md CHANGED
@@ -33,6 +33,7 @@ min-agent serve --host 0.0.0.0 --port 3000
33
33
  | GET | `/v1/update` | 检查 npm 是否有新版本(不执行安装) |
34
34
  | GET | `/v1/models` | 模型列表 |
35
35
  | GET | `/v1/context` | 上下文窗口信息 |
36
+ | POST | `/v1/context` | 设置本地模型上下文档位 |
36
37
  | GET | `/v1/project` | 项目扫描 |
37
38
  | POST | `/v1/chat` | 对话(项目感知) |
38
39
  | POST | `/v1/code` | 与 `/v1/chat` 相同(兼容路径) |
@@ -118,10 +119,26 @@ CLI 升级请使用 `min-agent update`(执行 `npm install -g min-agent`)。
118
119
  ## `GET /v1/context`
119
120
 
120
121
  ```json
121
- { "context_window": 1000000, "source": "config", "model": "gpt-4o" }
122
+ { "context_window": 1000000, "source": "config", "model": "gpt-4o", "configurable": false, "level": null }
122
123
  ```
123
124
 
124
- `source` 为 `config`(配置中的 `contextWindow`)、`detected`(从接口读到)或 `fallback`(未读到,按 512k 处理)。
125
+ `source` 为 `config`(配置中的 `contextWindow`)、`detected`(从接口读到)或 `fallback`(未读到:Ollama 按 2048,其余按 512k)。Ollama 的 detected 值是运行时窗口(优先 Modelfile `num_ctx`,否则不超过 32768),不是架构标称上限。对话请求走原生 `/api/chat` 并带上该窗口;只改 OpenAI 兼容接口的 `num_ctx` 不会生效。
126
+
127
+ 当前为 Ollama 时额外返回 `configurable: true` 与 `level`(`2k`…`256k` 或 `auto`)。其它 provider 为 `configurable: false`、`level: null`。
128
+
129
+ ## `POST /v1/context`
130
+
131
+ 仅 Ollama provider。将当前 provider 的 `contextWindow` 写成对应档位;`auto` 删除覆盖,回到探测值。
132
+
133
+ ```json
134
+ { "level": "8k" }
135
+ ```
136
+
137
+ ```json
138
+ { "ok": true, "context_window": 8192, "source": "config", "model": "qwen3:4b", "configurable": true, "level": "8k" }
139
+ ```
140
+
141
+ 非法档位返回 `400 invalid_context_level`。当前不是 Ollama 时返回 `400 ctx_not_supported`。
125
142
 
126
143
  ## `GET /v1/project`
127
144