newmark-agent 0.4.2 → 0.4.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.
@@ -52,7 +52,9 @@ export interface NormalizedAgentRequest {
52
52
  tools: NormalizedTool[];
53
53
  temperature: number;
54
54
  maxOutputTokens: number;
55
- reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
55
+ /** dev-0.4.3:模型原生思考强度档位名。Newmark 档位经模型配置
56
+ * `thinking_tier_map` 映射后可能不再是 Newmark 档位名,故为自由字符串。 */
57
+ reasoningEffort?: string;
56
58
  apiKey: string;
57
59
  baseUrl: string;
58
60
  /** 可选的会话标识。仅当目标 provider 显式支持 session_id 语义的上下文缓存
@@ -350,7 +350,7 @@ class ToolExecutor {
350
350
  t('subagent_result', 'Return the persisted transcript, mailbox summary, status, and latest result for a peer agent. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
351
351
  t('subagent_close', 'Close a same-conversation peer. Root can close any peer; a peer can close only itself. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
352
352
  t('linked_plan', 'Read or update the current conversation linked Markdown plan. Update requires the current expected_revision.', { action: { type: 'string', enum: ['get', 'update'] }, markdown: { type: 'string' }, expected_revision: { type: 'number' } }, ['action']),
353
- t('build_history_query', 'Read the concrete public work details of one historical Build Block. The prompt already exposes user input, final summary, and completion status; call this read-only tool only when the user asks what specifically happened. Select by newest-to-oldest history_index, or by run_id returned from an earlier query. Every activity/guide content is bounded to max_chars (default 2000) to keep the read lean and cache-friendly.', { history_index: { type: 'number', minimum: 1, description: '1-based historical Build Block index from the request ledger; 1 is the newest previous task.' }, run_id: { type: 'string', description: 'Exact run id returned by an earlier build_history_query result.' }, max_events: { type: 'number', minimum: 1, maximum: 200, description: 'Maximum trailing public work events; defaults to 80.' }, max_chars: { type: 'number', minimum: 100, maximum: 4000, description: 'Per-event/per-guide content character bound; defaults to 2000.' } }, []),
353
+ t('build_history_query', 'Read the concrete public work details (tool calls, results, file changes, guides) of one historical Build Block. Call it proactively when the current task continues, fixes, verifies, or depends on earlier work: reuse the returned activity instead of re-investigating from scratch. Do not call it merely to answer completion status already exposed by the prompt. Select by newest-to-oldest history_index, or by run_id returned from an earlier query. Every activity/guide content is bounded to max_chars (default 2000) to keep the read lean and cache-friendly.', { history_index: { type: 'number', minimum: 1, description: '1-based historical Build Block index from the request ledger; 1 is the newest previous task.' }, run_id: { type: 'string', description: 'Exact run id returned by an earlier build_history_query result.' }, max_events: { type: 'number', minimum: 1, maximum: 200, description: 'Maximum trailing public work events; defaults to 80.' }, max_chars: { type: 'number', minimum: 100, maximum: 4000, description: 'Per-event/per-guide content character bound; defaults to 2000.' } }, []),
354
354
  t('context_compress', 'Actively compress the LLM context history for this conversation. This collapses older history entries into a concise summary while preserving the recent tail, which reduces context tokens and cost. IMPORTANT: it affects only the LLM context (what the model sees); the displayed conversation history shown to the user is never altered. Call this when the conversation is long, token pressure is high, or you judge that older turns are no longer needed in full. Idempotent and safe: repeated calls produce incremental summaries.', { keep_recent: { type: 'number', minimum: 2, maximum: 60, description: 'Recent message count to keep uncompressed at the tail. Defaults to the configured keep_recent_messages.' }, force: { type: 'boolean', description: 'Compress even if the context is not yet over the automatic threshold. Defaults to false.' } }, []),
355
355
  t('context_history_manage', 'Manage the LLM context history for this conversation without affecting the displayed conversation history. This is the active context-management surface. The hot cache stays bounded; evicted folded segments remain in a conversation-isolated append-only cold archive and are loaded only by explicit search/read/restore calls. Actions: list returns a bounded index of current context entries; remove declares one long-term entry for unload (see below); summarize folds a contiguous current range; restore reinserts a folded segment when its summary marker is still present; search finds matching hot or archived segments; read returns one bounded segment without injecting the whole archive; status reports budgets, hot cache, cold archive, the protected recent zone, and pending removals. The recent context tail and last user message are protected from remove/summarize unless dangerous is true. For cache-optimization, remove ONLY targets long-term history (never the protected recent tail or last user message) and does NOT unload immediately: the declared entry stays in context for the rest of the current Build Block so the provider prefix cache stays stable, then is physically removed when the Block ends — applying to subsequent Blocks only.', {
356
356
  action: { type: 'string', enum: ['list', 'remove', 'summarize', 'restore', 'search', 'read', 'status'], description: 'list current entries; remove one; summarize a range; restore by restore_id; search hot/cold folded segments; read one bounded folded segment; status report context budgets and storage.' },
@@ -372,7 +372,9 @@ class ToolExecutor {
372
372
  t('background_tool', 'Run a tool call in the background WITHOUT blocking the conversation turn. Pass the target tool name and its arguments; this tool returns a background_id IMMEDIATELY, and the real tool keeps running in the background. The result is persisted and can be retrieved later with read_tool_result. Use this for long-running or non-critical tools (bash, web_fetch, long read/grep) so the conversation continues without waiting. The background result stays OUT of context until you explicitly read it, preserving prompt-cache hit rate. Orchestration/flow/subagent/question tools cannot be backgrounded.', { tool: { type: 'string', description: 'The tool name to run in the background (e.g. bash, web_fetch, read, grep).' }, args: { type: 'object', description: 'The arguments object for the target tool, matching its normal schema.' } }, ['tool']),
373
373
  t('read_tool_result', 'Read the result of a background tool. Pass the background_id returned by background_tool. When status is running, returns a running marker; when done, returns the persisted result (optionally release it from storage after reading); when error, returns the failure. Background results are released from storage only when you set release=true.', { background_id: { type: 'string', description: 'The background_id returned by background_tool.' }, release: { type: 'boolean', description: 'Set true to release the persisted result from storage after reading it.' } }, ['background_id']),
374
374
  t('goal_manage', 'Actively manage the persistent Goal state for this conversation. You may enter Goal mode, update (edit) its objective, mark it complete, or exit Goal mode yourself. Call this when the user asks you to pursue a persistent objective, when the objective changes, when you have verified the objective is genuinely achieved, or when you judge the Goal is no longer needed and should be cleared. This is the agent-side state control that mirrors the GUI goal panel controls. enter/update require objective; complete marks the objective verified and exits Goal mode; exit clears the Goal (and returns to Build mode) without claiming completion.', { action: { type: 'string', enum: ['enter', 'update', 'complete', 'exit'], description: 'enter=enter Goal mode and set the objective; update=edit the objective (records a change); complete=mark the objective verified-achieved and exit Goal mode; exit=clear the Goal and return to Build mode without claiming completion.' }, objective: { type: 'string', description: 'The Goal objective text. Required for enter and update.' }, reason: { type: 'string', description: 'Optional one-line reason for the state change, recorded for audit.' } }, ['action']),
375
- t('conversation_rename', 'Rename the CURRENT conversation to a concise, descriptive title you choose. On the FIRST Build Block of a NEW conversation the runtime asks you to call this once so the conversation list shows a meaningful name instead of an auto-generated one. Keep the title short (a few words) and cache-friendly: a concrete noun phrase describing the task, never a sentence or quoted prompt.', { title: { type: 'string', description: 'The new conversation title (a short noun phrase, e.g. "Fix TUI color leak", "Add goal_manage tool").' } }, ['title']),
375
+ t('task_read', 'Read the persistent inline task checklist of the CURRENT conversation (the same list the GUI Task panel and TUI plan view render). Returns bounded items (id, status, task text <=240 chars) plus unfinished count. Read-only and side-effect free; call this whenever you need concrete task-list state instead of assuming it. Kept out of the system prompt so provider prefix caching stays stable.', {}, []),
376
+ t('task_create', 'Maintain the persistent inline task checklist of the CURRENT conversation. This is the durable replacement for ephemeral in-reply checklists: items you create here appear in the GUI Task panel and TUI plan view immediately and persist across Build Blocks. Actions: create appends one task item; update changes status (pending|in_progress|done) or text of one item by id or index; clear removes completed items. Use create when starting a multi-step task, update as each item progresses, and update status=done when verified. Returns a compact confirmation, never the full list.', { action: { type: 'string', enum: ['create', 'update', 'clear'], description: 'create=append a task item; update=change one item status/text; clear=remove completed items.' }, task: { type: 'string', description: 'Task text for create, or new text for update. Short actionable label (<=400 chars).' }, text: { type: 'string', description: 'Alias of task.' }, id: { type: 'string', description: 'Item id from task_read for update.' }, index: { type: 'number', description: '0-based item index for update (alternative to id).' }, status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'blocked'], description: 'New status for update; blocked is stored as pending.' } }, ['action']),
377
+ t('conversation_rename', 'Rename the CURRENT conversation to a concise, descriptive title you choose. Use this when the user asks to rename the conversation or when the current auto-generated title no longer describes the work. Keep the title short (a few words) and cache-friendly: a concrete noun phrase describing the task, never a sentence or quoted prompt.', { title: { type: 'string', description: 'The new conversation title (a short noun phrase, e.g. "Fix TUI color leak", "Add goal_manage tool").' } }, ['title']),
376
378
  t('question', 'Ask user a multiple-choice question', { questions: { type: 'array' } }, ['questions']),
377
379
  t('skill_download', 'Download a skill', { name: { type: 'string' }, source: { type: 'string' } }, ['name', 'source']),
378
380
  t('skill', 'Search enabled skill metadata or load one exact skill body on demand. Use query when unsure, then name to load the selected skill.', { query: { type: 'string', maxLength: 200 }, name: { type: 'string', maxLength: 200 } }, []),
@@ -51,8 +51,10 @@ const ESC = "\u001b[";
51
51
  function createPaintScheduler(state, output = process.stdout, renderFrame = render) {
52
52
  let pending = false;
53
53
  let lastFrame = "";
54
+ let cancelled = false;
54
55
  const flush = () => {
55
56
  pending = false;
57
+ if (cancelled) return;
56
58
  const frame = renderFrame(state);
57
59
  if (frame === lastFrame) return false;
58
60
  lastFrame = frame;
@@ -65,6 +67,8 @@ function createPaintScheduler(state, output = process.stdout, renderFrame = rend
65
67
  setImmediate(flush);
66
68
  };
67
69
  paint.flush = flush;
70
+ // 退出前调用:丢弃任何排队中的重绘,避免恢复主屏后又被画上一帧 TUI 画面。
71
+ paint.cancel = () => { cancelled = true; pending = false; };
68
72
  return paint;
69
73
  }
70
74
 
@@ -108,6 +112,28 @@ function resolveTuiWorkspacePath(args, options = {}) {
108
112
  return explicitRoot || process.cwd();
109
113
  }
110
114
 
115
+ // 在 Windows 传统控制台(ConHost)下,输出句柄默认不启用
116
+ // ENABLE_VIRTUAL_TERMINAL_PROCESSING,导致备用屏 ?1049h / 清屏 2J 等 ANSI
117
+ // 序列不被解析,主屏保留滚动历史(滚轮能滚回旧帧)。Electron-as-node sidecar
118
+ // 也不会像纯 Node 那样由 libuv 自动启用 VT。这里主动给输出句柄启用 VT 处理,
119
+ // 给输入句柄启用原始输入,作为 main.ts 之外入口的保险。
120
+ function enableWindowsVirtualTerminal() {
121
+ if (process.platform !== 'win32') return;
122
+ try {
123
+ const { spawnSync } = require('node:child_process');
124
+ const script = [
125
+ 'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class NewmarkTuiVT { [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n); [DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m); [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m); }\'',
126
+ '$o = [NewmarkTuiVT]::GetStdHandle(-11); $om = [uint32]0; if ([NewmarkTuiVT]::GetConsoleMode($o, [ref]$om)) { [NewmarkTuiVT]::SetConsoleMode($o, ($om -bor 4)) | Out-Null }',
127
+ '$i = [NewmarkTuiVT]::GetStdHandle(-10); $im = [uint32]0; if ([NewmarkTuiVT]::GetConsoleMode($i, [ref]$im)) { [NewmarkTuiVT]::SetConsoleMode($i, (($im -band (-bnot 7)) -bor 512)) | Out-Null }',
128
+ ].join('; ');
129
+ spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
130
+ stdio: 'ignore',
131
+ windowsHide: true,
132
+ timeout: 3000,
133
+ });
134
+ } catch {}
135
+ }
136
+
111
137
  function start(options = {}) {
112
138
  const forcedTerminal = process.env.NEWMARK_FORCE_TTY === "1";
113
139
  if ((!process.stdin.isTTY || !process.stdout.isTTY) && !forcedTerminal) {
@@ -115,6 +141,9 @@ function start(options = {}) {
115
141
  process.exitCode = 1;
116
142
  return;
117
143
  }
144
+ // 备用屏依赖输出句柄的 VT 处理;纯 Node 的 libuv 会自动启用,但
145
+ // Electron-as-node sidecar 或强制 TTY 路径不会,这里补上。
146
+ if (forcedTerminal || !process.stdout.isTTY) enableWindowsVirtualTerminal();
118
147
 
119
148
  const args = process.argv.slice(2);
120
149
  let adapter;
@@ -140,24 +169,38 @@ function start(options = {}) {
140
169
  let timer = null;
141
170
  let animationTimer = null;
142
171
  let closing = false;
172
+ // 标准 TUI 屏幕模型:进入备用屏幕缓冲(alternate screen buffer)。
173
+ // 备用屏没有主屏的滚动历史:每次刷新用 2J 全量重绘而不是滚动新页面,
174
+ // 鼠标滚轮无法滚回上一次刷新,终端滚动也不会让画面错位(否则高亮行
175
+ // 会随终端滚动"离开屏幕")。
176
+ const enterAltScreen = () => process.stdout.write(`${ESC}?1049h${ESC}?25l${ESC}2J${ESC}H`);
177
+ const leaveAltScreen = () => `${ESC}?25h${ESC}?1049l${ESC}0m${ESC}2J${ESC}H`;
143
178
  const paint = createPaintScheduler(state);
144
179
  const cleanup = () => {
145
180
  if (timer) clearInterval(timer);
146
181
  if (animationTimer) clearInterval(animationTimer);
147
182
  if (typeof process.stdin.setRawMode === "function") process.stdin.setRawMode(false);
148
183
  process.stdin.pause();
149
- process.stdout.write(`${ESC}?25h${ESC}0m${ESC}2J${ESC}H`);
150
184
  };
151
185
  const quit = () => {
152
186
  if (closing) return;
153
187
  closing = true;
188
+ paint.cancel();
154
189
  cleanup();
155
190
  if (typeof state.adapter.close === "function") state.adapter.close();
156
- process.stdout.write(state.adapterKind === "mock"
157
- ? "Newmark TUI demo closed. No state was saved.\n"
158
- : "Newmark TUI closed. Conversation and settings state are persisted by Newmark.\n");
191
+ // 退出序列与关闭消息用 fs.writeSync 同步写入 fd:直接进入内核管道,
192
+ // 不经过异步流缓冲,process.exit() 不会丢弃任何字节(此前异步 write
193
+ // 在退出瞬间被 ConPTY/管道丢弃,导致恢复主屏后看不到退出消息)。
194
+ try {
195
+ const fs = require("node:fs");
196
+ const fd = process.stdout.fd || 1;
197
+ fs.writeSync(fd, `${leaveAltScreen()}\n`);
198
+ fs.writeSync(fd, state.adapterKind === "mock"
199
+ ? "Newmark TUI demo closed. No state was saved.\n"
200
+ : "Newmark TUI closed. Conversation and settings state are persisted by Newmark.\n");
201
+ } catch {}
159
202
  process.exitCode = 0;
160
- setImmediate(() => process.exit(0));
203
+ process.exit(0);
161
204
  };
162
205
 
163
206
  function simulateReply(text) {
@@ -554,8 +597,10 @@ function start(options = {}) {
554
597
  process.stdin.resume();
555
598
  process.stdin.on("keypress", handleKey);
556
599
  process.stdout.on("resize", paint);
557
- process.on("exit", () => process.stdout.write(`${ESC}?25h${ESC}0m`));
600
+ // 兜底:任何退出路径(包括未捕获异常后的 exit 事件)都恢复主屏缓冲与光标。
601
+ process.on("exit", () => process.stdout.write(`${ESC}?25h${ESC}?1049l${ESC}0m`));
558
602
  process.on("SIGTERM", quit);
603
+ enterAltScreen();
559
604
  paint();
560
605
  }
561
606
 
@@ -204,6 +204,34 @@ const providers = [
204
204
  }
205
205
  ];
206
206
 
207
+ // Demo-only long-list injection: NEWMARK_TUI_DEMO_MODELS=<count> appends a
208
+ // dedicated stress provider with <count> models so the featureless --demo TUI
209
+ // can exercise long model-selection cursor-follow behavior without any runtime.
210
+ const DEMO_MODEL_COUNT = Number(process.env.NEWMARK_TUI_DEMO_MODELS || 0);
211
+ if (Number.isFinite(DEMO_MODEL_COUNT) && DEMO_MODEL_COUNT > 0) {
212
+ providers.push({
213
+ id: "provider-demo-stress",
214
+ name: "Demo Stress",
215
+ base_url: "https://demo.invalid/v1",
216
+ api_key: "",
217
+ has_api_key: false,
218
+ protocol: "openai",
219
+ enabled: true,
220
+ models: Array.from({ length: DEMO_MODEL_COUNT }, (_, index) => ({
221
+ name: `demo-stress-model-${String(index).padStart(3, "0")}`,
222
+ display: `Stress Model ${String(index).padStart(3, "0")}`,
223
+ description: `Long-list cursor-follow stress model ${index} description`,
224
+ max_tokens: 128000,
225
+ vision: false,
226
+ thinking: false,
227
+ enabled: true,
228
+ speed_rating: "unknown",
229
+ capability_rating: "unknown",
230
+ validation: { status: "unavailable", level: "discovered", checked_at: "" }
231
+ }))
232
+ });
233
+ }
234
+
207
235
  const flows = [
208
236
  {
209
237
  name: "release-readiness",
@@ -430,19 +430,28 @@ function chatView(state, width, height, p) {
430
430
  `${p.bold}Conversations${p.reset}`,
431
431
  `${p.muted}N new${p.reset}`,
432
432
  "",
433
- ...state.snapshot.conversations.map((item, i) => {
434
- const style = state.focusRegion === "content" && i === state.selected && !state.inputMode ? `${p.selected}${p.bold}` : "";
435
- const running = state.runningConversationKeys?.has(`${state.target.workspaceId}::${item.id}`);
436
- const marker = running
437
- ? `${p.cyan}${["\\", "—", "/", "—"][state.tick % 4]}${p.reset}`
438
- : item.title === state.lastConversation
439
- ? `${p.cyan}›${p.reset}`
440
- : item.pinned
441
- ? `${p.amber}◆${p.reset}`
442
- : "·";
443
- const updated = String(item.updatedAt || "").slice(11, 16) || "—";
444
- return `${style}${marker} ${pad(item.title, 19)} ${p.muted}${updated}${p.reset}`;
445
- })
433
+ ...(() => {
434
+ const conversationRows = state.snapshot.conversations.map((item, i) => {
435
+ const style = state.focusRegion === "content" && i === state.selected && !state.inputMode ? `${p.selected}${p.bold}` : "";
436
+ const running = state.runningConversationKeys?.has(`${state.target.workspaceId}::${item.id}`);
437
+ const marker = running
438
+ ? `${p.cyan}${["\\", "—", "/", "—"][state.tick % 4]}${p.reset}`
439
+ : item.title === state.lastConversation
440
+ ? `${p.cyan}›${p.reset}`
441
+ : item.pinned
442
+ ? `${p.amber}◆${p.reset}`
443
+ : "·";
444
+ const updated = String(item.updatedAt || "").slice(11, 16) || "—";
445
+ return `${style}${marker} ${pad(item.title, 19)} ${p.muted}${updated}${p.reset}`;
446
+ });
447
+ const listViewport = Math.max(1, height - 3);
448
+ const listMaxScroll = Math.max(0, conversationRows.length - listViewport);
449
+ let listScroll = Math.max(0, Math.min(listMaxScroll, Number(state.conversationListScroll) || 0));
450
+ if (state.selected < listScroll) listScroll = state.selected;
451
+ else if (state.selected >= listScroll + listViewport) listScroll = state.selected - listViewport + 1;
452
+ state.conversationListScroll = Math.max(0, Math.min(listMaxScroll, listScroll));
453
+ return conversationRows.slice(state.conversationListScroll, state.conversationListScroll + listViewport);
454
+ })()
446
455
  ] : [];
447
456
  const preview = state.snapshot.conversations[state.selected] || state.snapshot.conversations[0] || {
448
457
  id: state.target.conversationId,
@@ -680,29 +689,34 @@ function modelView(state, width, p) {
680
689
  const isCurrent = (selection) => selection.kind === current.kind
681
690
  && (selection.kind === "auto"
682
691
  || (selection.providerId === current.providerId && selection.modelId === current.modelId));
683
- return [
692
+ const rows = [
684
693
  ...conversationContext(state, p, "Model and reasoning effort"),
685
- `${p.bold}${tr(state, "Reasoning effort")}${p.reset} ${p.muted}${tr(state, "Shared GUI/TUI request tier · ←/→ changes section")}${p.reset}`,
686
- ...INTELLIGENCE_TIERS.map((tier, index) => {
687
- const style = state.focusRegion === "content" && state.contentColumn === 0 && index === state.selected ? `${p.selected}${p.bold}` : "";
688
- if (style) state.contentFocusLine = 5 + index;
689
- return `${style} ${tier === currentTier ? `${p.cyan}●${p.reset}` : ""} ${tier}${p.reset}`;
690
- }),
694
+ `${p.bold}${tr(state, "Reasoning effort")}${p.reset} ${p.muted}${tr(state, "Shared GUI/TUI request tier · ←/→ changes section")}${p.reset}`
695
+ ];
696
+ const tierStart = rows.length;
697
+ INTELLIGENCE_TIERS.forEach((tier, index) => {
698
+ const style = state.focusRegion === "content" && state.contentColumn === 0 && index === state.selected ? `${p.selected}${p.bold}` : "";
699
+ if (style) state.contentFocusLine = tierStart + index;
700
+ rows.push(`${style} ${tier === currentTier ? `${p.cyan}●${p.reset}` : "○"} ${tier}${p.reset}`);
701
+ });
702
+ rows.push(
691
703
  "",
692
704
  `${p.bold}${tr(state, "Deployment")}${p.reset} ${p.muted}${tr(state, "Used by this conversation, including its Plan and Subagents")}${p.reset}`,
693
- "",
694
- ...options.flatMap((option, index) => {
695
- const style = state.focusRegion === "content" && state.contentColumn === 1 && index === state.selected ? `${p.selected}${p.bold}` : "";
696
- if (style) state.contentFocusLine = 14 + index * 2;
697
- const marker = isCurrent(option.selection) ? `${p.cyan}●${p.reset}` : "";
698
- return [
699
- `${style} ${marker} ${pad(option.label, Math.max(18, Math.min(32, width - 24)))} ${p.muted}${option.provider}${p.reset}`,
700
- ` ${p.muted}${truncate(option.description, Math.max(20, width - 5))}${p.reset}`
701
- ];
702
- }),
705
+ ""
706
+ );
707
+ const deploymentStart = rows.length;
708
+ options.forEach((option, index) => {
709
+ const style = state.focusRegion === "content" && state.contentColumn === 1 && index === state.selected ? `${p.selected}${p.bold}` : "";
710
+ if (style) state.contentFocusLine = deploymentStart + index * 2;
711
+ const marker = isCurrent(option.selection) ? `${p.cyan}●${p.reset}` : "○";
712
+ rows.push(`${style} ${marker} ${pad(option.label, Math.max(18, Math.min(32, width - 24)))} ${p.muted}${option.provider}${p.reset}`);
713
+ rows.push(` ${p.muted}${truncate(option.description, Math.max(20, width - 5))}${p.reset}`);
714
+ });
715
+ rows.push(
703
716
  "",
704
717
  `${p.muted}${tr(state, "Enter applies the focused tier or deployment. Effort persists globally; deployments remain per conversation.")}${p.reset}`
705
- ];
718
+ );
719
+ return rows;
706
720
  }
707
721
 
708
722
  function flowBarView(state, width, p) {
@@ -736,20 +750,19 @@ function flowListView(state, width, p) {
736
750
 
737
751
  function flowTaskView(state, width, p) {
738
752
  const components = state.currentFlow?.components || [];
739
- return [
753
+ const rows = [
740
754
  ...conversationContext(state, p, "Flow task"),
741
755
  `${p.bold}${tr(state, "Flow Task")}${p.reset} ${p.muted}${state.currentFlow?.name || "No workflow"}${p.reset}`,
742
- "",
743
- ...components.flatMap((component, index) => {
744
- const style = state.focusRegion === "content" && index === state.selected ? `${p.selected}${p.bold}` : "";
745
- if (style) state.contentFocusLine = 6 + index * 2;
746
- const mode = component.type === "logic" ? "LOGIC" : String(component.mode || "BUILD").toUpperCase();
747
- return [
748
- `${style} ${String(component.id).padStart(2)} ${pad(mode, 7)} ${truncate(component.prompt, Math.max(18, width - 16))}`,
749
- component.type === "logic" ? ` ${p.muted}true → ${component.goto_true} · false → ${component.goto_false}${p.reset}` : ""
750
- ].filter(Boolean);
751
- })
756
+ ""
752
757
  ];
758
+ components.forEach((component, index) => {
759
+ const style = state.focusRegion === "content" && index === state.selected ? `${p.selected}${p.bold}` : "";
760
+ if (style) state.contentFocusLine = rows.length;
761
+ const mode = component.type === "logic" ? "LOGIC" : String(component.mode || "BUILD").toUpperCase();
762
+ rows.push(`${style} ${String(component.id).padStart(2)} ${pad(mode, 7)} ${truncate(component.prompt, Math.max(18, width - 16))}`);
763
+ if (component.type === "logic") rows.push(` ${p.muted}true → ${component.goto_true} · false → ${component.goto_false}${p.reset}`);
764
+ });
765
+ return rows;
753
766
  }
754
767
 
755
768
  function toolsView(state, width, p) {
@@ -1143,11 +1156,19 @@ function overlayLines(state, width, p) {
1143
1156
  }
1144
1157
  if (state.overlay === "palette") {
1145
1158
  const commands = filteredCommands(state);
1159
+ const paletteViewport = Math.min(7, Math.max(1, commands.length));
1160
+ const paletteMaxScroll = Math.max(0, commands.length - paletteViewport);
1161
+ let paletteScroll = Math.max(0, Math.min(paletteMaxScroll, Number(state.paletteScroll) || 0));
1162
+ if (state.paletteIndex < paletteScroll) paletteScroll = state.paletteIndex;
1163
+ else if (state.paletteIndex >= paletteScroll + paletteViewport) paletteScroll = state.paletteIndex - paletteViewport + 1;
1164
+ state.paletteScroll = Math.max(0, Math.min(paletteMaxScroll, paletteScroll));
1165
+ const visibleCommands = commands.slice(state.paletteScroll, state.paletteScroll + paletteViewport);
1146
1166
  const rows = [
1147
1167
  `${p.bold}>${p.reset} ${state.paletteQuery}${p.cyan}▏${p.reset}`,
1148
1168
  `${p.muted}${"─".repeat(Math.max(8, Math.min(64, width - 10)))}${p.reset}`,
1149
- ...(commands.length ? commands.slice(0, 7).map((command, i) => {
1150
- const style = i === state.paletteIndex ? `${p.selected}${p.bold}` : "";
1169
+ ...(visibleCommands.length ? visibleCommands.map((command, i) => {
1170
+ const commandIndex = state.paletteScroll + i;
1171
+ const style = commandIndex === state.paletteIndex ? `${p.selected}${p.bold}` : "";
1151
1172
  return `${style} ${pad(command.label, Math.max(18, Math.min(52, width - 18)))} ${p.muted}${command.hint}${p.reset}`;
1152
1173
  }) : [`${p.muted} No matching commands${p.reset}`]),
1153
1174
  "",
@@ -1156,14 +1177,22 @@ function overlayLines(state, width, p) {
1156
1177
  return card(rows, Math.min(72, width - 4), p, "Command palette");
1157
1178
  }
1158
1179
  if (state.overlay === "flow-select") {
1180
+ const flowViewport = Math.min(7, Math.max(1, state.flows.length));
1181
+ const flowMaxScroll = Math.max(0, state.flows.length - flowViewport);
1182
+ let flowScroll = Math.max(0, Math.min(flowMaxScroll, Number(state.flowSelectionScroll) || 0));
1183
+ if (state.flowSelectionIndex < flowScroll) flowScroll = state.flowSelectionIndex;
1184
+ else if (state.flowSelectionIndex >= flowScroll + flowViewport) flowScroll = state.flowSelectionIndex - flowViewport + 1;
1185
+ state.flowSelectionScroll = Math.max(0, Math.min(flowMaxScroll, flowScroll));
1186
+ const visibleFlows = state.flows.slice(state.flowSelectionScroll, state.flowSelectionScroll + flowViewport);
1159
1187
  return card([
1160
1188
  `${p.bold}Flow mode requires a workflow${p.reset}`,
1161
1189
  `${p.muted}This selection is bound to ${state.lastConversation}.${p.reset}`,
1162
1190
  "",
1163
- ...(state.flows.length
1164
- ? state.flows.map((name, index) => {
1165
- const style = index === state.flowSelectionIndex ? `${p.selected}${p.bold}` : "";
1166
- return `${style} ${index === state.flowSelectionIndex ? "›" : " "} ${name}${p.reset}`;
1191
+ ...(visibleFlows.length
1192
+ ? visibleFlows.map((name, i) => {
1193
+ const flowIndex = state.flowSelectionScroll + i;
1194
+ const style = flowIndex === state.flowSelectionIndex ? `${p.selected}${p.bold}` : "";
1195
+ return `${style} ${flowIndex === state.flowSelectionIndex ? "›" : " "} ${name}${p.reset}`;
1167
1196
  })
1168
1197
  : [`${p.amber}No workflows are configured in ~/.Newmark/Flow.${p.reset}`]),
1169
1198
  "",
@@ -1226,14 +1255,38 @@ function overlayLines(state, width, p) {
1226
1255
  return [];
1227
1256
  }
1228
1257
 
1229
- function render(state, columns = process.stdout.columns || 100, rows = process.stdout.rows || 30) {
1258
+ // 实时读取终端窗口尺寸:优先 getWindowSize()(每次调用都查询底层 TTY 尺寸,
1259
+ // 不依赖 Node 缓存的 columns/rows,避免 resize 事件丢失或延迟时按旧尺寸绘制),
1260
+ // 其次回退到缓存 columns/rows,再回退 COLUMNS/LINES 环境变量与默认值。
1261
+ function readWindowSize(fallbackColumns = 100, fallbackRows = 30) {
1262
+ const stdout = process.stdout;
1263
+ if (stdout && typeof stdout.getWindowSize === "function") {
1264
+ try {
1265
+ const [width, height] = stdout.getWindowSize();
1266
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
1267
+ return { columns: Math.max(1, Math.floor(width)), rows: Math.max(1, Math.floor(height)) };
1268
+ }
1269
+ } catch {}
1270
+ }
1271
+ const columns = Number(stdout?.columns) || Number(process.env.COLUMNS) || fallbackColumns;
1272
+ const rows = Number(stdout?.rows) || Number(process.env.LINES) || fallbackRows;
1273
+ return { columns: Math.max(1, Math.floor(columns)), rows: Math.max(1, Math.floor(rows)) };
1274
+ }
1275
+
1276
+ function render(state, columns, rows) {
1277
+ const size = (columns !== undefined && rows !== undefined)
1278
+ ? { columns: Math.max(1, Math.floor(Number(columns) || 1)), rows: Math.max(1, Math.floor(Number(rows) || 1)) }
1279
+ : readWindowSize();
1230
1280
  const p = palette(state);
1231
- const width = Math.max(52, columns);
1232
- const height = Math.max(20, rows);
1281
+ // 严格约束:直接采用终端实际尺寸,绝不通过 Math.max(52/20) 强制放大。
1282
+ // 终端窗口小于内部最小布局时,内容在输出阶段裁剪到窗口内,否则帧会
1283
+ // 画到窗口之外(用户看不到底部行/右侧列,如小窗口下的模型长菜单)。
1284
+ const width = size.columns;
1285
+ const height = size.rows;
1233
1286
  const compact = width < 78;
1234
1287
  const sidebarWidth = compact ? 0 : 22;
1235
1288
  const contentWidth = width - sidebarWidth - 2;
1236
- const bodyHeight = height - 3;
1289
+ const bodyHeight = Math.max(1, height - 3);
1237
1290
  const sidebar = sidebarWidth ? renderSidebar(state, bodyHeight, sidebarWidth, p) : [];
1238
1291
  const content = renderContent(state, contentWidth, bodyHeight, p);
1239
1292
  let contentLines = content;
@@ -1242,8 +1295,11 @@ function render(state, columns = process.stdout.columns || 100, rows = process.s
1242
1295
  let scroll = Math.max(0, Math.min(maximumScroll, Number(state.contentScroll) || 0));
1243
1296
  const focusLine = Number(state.contentFocusLine) || -1;
1244
1297
  if (focusLine >= 0) {
1245
- if (focusLine < scroll) scroll = focusLine;
1246
- else if (focusLine >= scroll + bodyHeight) scroll = focusLine - bodyHeight + 1;
1298
+ // 居中跟随:焦点行尽量保持在视口中上部,上下都留出内容,避免选中行
1299
+ // 贴住视口边缘或被裁掉其相邻行(如两行一组的模型选项主行+描述行)。
1300
+ const targetLine = Math.min(Math.max(0, Math.floor(bodyHeight / 3)), Math.max(0, bodyHeight - 2));
1301
+ if (focusLine < scroll + targetLine) scroll = Math.max(0, focusLine - targetLine);
1302
+ else if (focusLine >= scroll + bodyHeight - 1 - targetLine) scroll = Math.min(maximumScroll, focusLine - (bodyHeight - 1 - targetLine));
1247
1303
  }
1248
1304
  scroll = Math.max(0, Math.min(maximumScroll, scroll));
1249
1305
  state.contentScroll = scroll;
@@ -1262,20 +1318,25 @@ function render(state, columns = process.stdout.columns || 100, rows = process.s
1262
1318
  : "";
1263
1319
  if (compact) body.unshift(pad(top, width));
1264
1320
  const focus = state.focusRegion === "menu" ? "MENU" : "CONTENT";
1265
- const footer = `${p.panel} ${p.cyan}${focus}${p.reset}${p.panel}${p.muted} · ${truncate(state.notice, width - 42)}${p.reset}${p.panel}${" ".repeat(Math.max(1, width - visibleLength(state.notice) - visibleLength(focus) - 35))}Tab back ? help Q quit ${p.reset}`;
1266
- const renderedBody = body.slice(0, height - 1);
1267
- while (renderedBody.length < height - 1) renderedBody.push(pad("", width));
1268
- let output = `${ESC}?25l${ESC}2J${ESC}H${p.paint}${renderedBody.join("\n")}\n${pad(footer, width)}`;
1321
+ const footer = `${p.panel} ${p.cyan}${focus}${p.reset}${p.panel}${p.muted} · ${truncate(state.notice, Math.max(1, width - 42))}${p.reset}${p.panel}${" ".repeat(Math.max(1, width - visibleLength(state.notice) - visibleLength(focus) - 35))}Tab back ? help Q quit ${p.reset}`;
1322
+ const bodyLines = Math.max(1, height - 1);
1323
+ const renderedBody = body.slice(0, bodyLines);
1324
+ while (renderedBody.length < bodyLines) renderedBody.push(pad("", width));
1325
+ // 输出阶段严格边界:每行按可见宽度截断到窗口宽度,行数限制在窗口高度内,
1326
+ // 确保无论内部最小布局如何,帧绝不会画到窗口之外。
1327
+ const boundedBody = renderedBody.map((line) => truncate(line, width));
1328
+ const boundedFooter = truncate(pad(footer, width), width);
1329
+ let output = `${ESC}?25l${ESC}2J${ESC}H${p.paint}${boundedBody.join("\n")}\n${boundedFooter}`;
1269
1330
  const overlay = overlayLines(state, width, p);
1270
1331
  if (overlay.length) {
1271
1332
  const overlayWidth = Math.max(...overlay.map(visibleLength));
1272
1333
  const x = Math.max(1, Math.floor((width - overlayWidth) / 2) + 1);
1273
1334
  const y = Math.max(2, Math.floor((height - overlay.length) / 2));
1274
1335
  overlay.forEach((line, index) => {
1275
- output += `${ESC}${y + index};${x}H${line}`;
1336
+ output += `${ESC}${y + index};${x}H${truncate(line, width)}`;
1276
1337
  });
1277
1338
  }
1278
1339
  return `${output}${p.final}`;
1279
1340
  }
1280
1341
 
1281
- module.exports = { render, stripAnsi, visibleLength, wrapText };
1342
+ module.exports = { render, readWindowSize, stripAnsi, visibleLength, wrapText };
@@ -183,6 +183,8 @@ function createState(options = {}) {
183
183
  workflowDraft: { name: "", mode: "build", prompt: "" },
184
184
  workflowFormIndex: 0,
185
185
  flowSelectionIndex: 0,
186
+ flowSelectionScroll: 0,
187
+ conversationListScroll: 0,
186
188
  flowByConversation: initialFlow ? { [`${target.workspaceId}::${target.conversationId}`]: initialFlow } : {},
187
189
  currentFlow: initialFlow,
188
190
  theme: appearance.theme,
@@ -192,6 +194,7 @@ function createState(options = {}) {
192
194
  settingChoiceIndex: 0,
193
195
  paletteQuery: "",
194
196
  paletteIndex: 0,
197
+ paletteScroll: 0,
195
198
  inputMode: false,
196
199
  input: "",
197
200
  inputCursor: 0,