codemini-cli 0.3.7 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/tools.js CHANGED
@@ -19,6 +19,7 @@ import { checkReadDedup } from './agent-loop.js';
19
19
  import { TOOL_SKIP_DIRS as SKIP_DIRS, TEXT_EXTENSIONS, CODE_WRITE_GUARD_EXTENSIONS, LANGUAGE_FILE_TYPES } from './constants.js';
20
20
  import { sha256Prefixed as sha256, sha256 as sha256Hash } from './crypto-utils.js';
21
21
  import { forgetMemory, listMemories, rememberMemory, searchMemories } from './memory-store.js';
22
+ import { normalizePlanState } from './plan-state.js';
22
23
  import { normalizeTodos } from './todo-state.js';
23
24
  const BACKGROUND_TASK_RECENT_OUTPUT_LIMIT = 80;
24
25
  const BACKGROUND_TASK_POLL_MS = 150;
@@ -1534,7 +1535,7 @@ async function editTarget(root, args) {
1534
1535
  throw new Error(`edit does not support kind: ${kind}`);
1535
1536
  }
1536
1537
 
1537
- export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate }) {
1538
+ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSystemEvent, getTodos, onTodosUpdate, getPlanState, onPlanStateUpdate }) {
1538
1539
  const emitSystemTool = (event) => {
1539
1540
  if (typeof onSystemEvent === 'function' && event) onSystemEvent(event);
1540
1541
  };
@@ -1791,6 +1792,76 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
1791
1792
  }
1792
1793
  }
1793
1794
  },
1795
+ {
1796
+ type: 'function',
1797
+ function: {
1798
+ name: 'read_plan',
1799
+ description:
1800
+ 'Read the structured plan state for the current session. Use this to recover plan progress after transient model/tool errors before continuing implementation.',
1801
+ parameters: {
1802
+ type: 'object',
1803
+ properties: {
1804
+ include_steps: { type: 'boolean', description: 'Include normalized plan steps in the output (default: true)' }
1805
+ },
1806
+ required: []
1807
+ }
1808
+ }
1809
+ },
1810
+ {
1811
+ type: 'function',
1812
+ function: {
1813
+ name: 'update_plan',
1814
+ description:
1815
+ 'Create, replace, or clear the structured plan state for the current session. Use clear=true to remove plan state.',
1816
+ parameters: {
1817
+ type: 'object',
1818
+ properties: {
1819
+ clear: { type: 'boolean', description: 'Set true to clear current plan state' },
1820
+ plan: {
1821
+ type: 'object',
1822
+ properties: {
1823
+ status: { type: 'string', description: 'Plan lifecycle status (for example pending_approval, approved, completed, failed)' },
1824
+ source: { type: 'string', description: 'Plan source such as auto/manual/tool' },
1825
+ goal: { type: 'string', description: 'Original user goal for this plan' },
1826
+ filePath: { type: 'string', description: 'Plan markdown file path' },
1827
+ summary: { type: 'string', description: 'Short plan summary' },
1828
+ finalSummary: { type: 'string', description: 'Final planning summary shown for approval' },
1829
+ steps: {
1830
+ type: 'array',
1831
+ items: {
1832
+ type: 'object',
1833
+ properties: {
1834
+ title: { type: 'string' },
1835
+ role: { type: 'string' },
1836
+ task: { type: 'string' }
1837
+ }
1838
+ }
1839
+ }
1840
+ }
1841
+ },
1842
+ status: { type: 'string', description: 'Top-level alias for plan.status when plan is omitted' },
1843
+ source: { type: 'string', description: 'Top-level alias for plan.source when plan is omitted' },
1844
+ goal: { type: 'string', description: 'Top-level alias for plan.goal when plan is omitted' },
1845
+ filePath: { type: 'string', description: 'Top-level alias for plan.filePath when plan is omitted' },
1846
+ summary: { type: 'string', description: 'Top-level alias for plan.summary when plan is omitted' },
1847
+ finalSummary: { type: 'string', description: 'Top-level alias for plan.finalSummary when plan is omitted' },
1848
+ steps: {
1849
+ type: 'array',
1850
+ description: 'Top-level alias for plan.steps when plan is omitted',
1851
+ items: {
1852
+ type: 'object',
1853
+ properties: {
1854
+ title: { type: 'string' },
1855
+ role: { type: 'string' },
1856
+ task: { type: 'string' }
1857
+ }
1858
+ }
1859
+ }
1860
+ },
1861
+ required: []
1862
+ }
1863
+ }
1864
+ },
1794
1865
  {
1795
1866
  type: 'function',
1796
1867
  function: {
@@ -2164,6 +2235,42 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2164
2235
  newTodos: nextTodos
2165
2236
  };
2166
2237
  },
2238
+ read_plan: async (args = {}) => {
2239
+ const includeSteps = args?.include_steps !== false;
2240
+ const currentPlan = normalizePlanState(typeof getPlanState === 'function' ? getPlanState() : null);
2241
+ if (!includeSteps && currentPlan && Array.isArray(currentPlan.steps)) {
2242
+ const { steps, ...rest } = currentPlan;
2243
+ return {
2244
+ ok: true,
2245
+ plan: rest,
2246
+ hasPendingApproval: rest.status === 'pending_approval'
2247
+ };
2248
+ }
2249
+ return {
2250
+ ok: true,
2251
+ plan: currentPlan,
2252
+ hasPendingApproval: currentPlan?.status === 'pending_approval'
2253
+ };
2254
+ },
2255
+ update_plan: async (args = {}) => {
2256
+ const oldPlan = normalizePlanState(typeof getPlanState === 'function' ? getPlanState() : null);
2257
+ const shouldClear = args?.clear === true || args?.plan === null;
2258
+ const nextRaw = shouldClear
2259
+ ? null
2260
+ : args?.plan && typeof args.plan === 'object'
2261
+ ? args.plan
2262
+ : args;
2263
+ const nextPlan = normalizePlanState(nextRaw);
2264
+ if (typeof onPlanStateUpdate === 'function') {
2265
+ onPlanStateUpdate(nextPlan);
2266
+ }
2267
+ return {
2268
+ ok: true,
2269
+ oldPlan,
2270
+ newPlan: nextPlan,
2271
+ hasPendingApproval: nextPlan?.status === 'pending_approval'
2272
+ };
2273
+ },
2167
2274
  run: (args) => runCommand(workspaceRoot, config, args),
2168
2275
  remember_user: async (args = {}) => {
2169
2276
  const saved = await rememberMemory({
@@ -2312,6 +2419,56 @@ export function getBuiltinTools({ workspaceRoot = process.cwd(), config, onSyste
2312
2419
  return ['Updated todo list:', ...lines].join('\n');
2313
2420
  },
2314
2421
 
2422
+ read_plan(result) {
2423
+ if (!result || typeof result !== 'object') return String(result);
2424
+ const plan = normalizePlanState(result.plan);
2425
+ if (!plan) return 'No active plan state.';
2426
+ const lines = [
2427
+ 'Current plan state:',
2428
+ `- status: ${plan.status || '-'}`,
2429
+ `- source: ${plan.source || '-'}`,
2430
+ `- goal: ${plan.goal || '-'}`,
2431
+ `- filePath: ${plan.filePath || '-'}`,
2432
+ `- summary: ${plan.summary || '-'}`,
2433
+ `- finalSummary: ${plan.finalSummary || '-'}`
2434
+ ];
2435
+ const steps = Array.isArray(plan.steps) ? plan.steps : [];
2436
+ if (steps.length > 0) {
2437
+ lines.push('- steps:');
2438
+ for (let i = 0; i < Math.min(steps.length, 8); i += 1) {
2439
+ const step = steps[i];
2440
+ lines.push(` ${i + 1}. [${step.role || '-'}] ${step.title || '-'} :: ${step.task || '-'}`);
2441
+ }
2442
+ if (steps.length > 8) lines.push(` ... and ${steps.length - 8} more step(s)`);
2443
+ }
2444
+ return lines.join('\n');
2445
+ },
2446
+
2447
+ update_plan(result) {
2448
+ if (!result || typeof result !== 'object') return String(result);
2449
+ const nextPlan = normalizePlanState(result.newPlan);
2450
+ if (!nextPlan) return 'Plan state cleared.';
2451
+ const lines = [
2452
+ 'Current plan state:',
2453
+ `- status: ${nextPlan.status || '-'}`,
2454
+ `- source: ${nextPlan.source || '-'}`,
2455
+ `- goal: ${nextPlan.goal || '-'}`,
2456
+ `- filePath: ${nextPlan.filePath || '-'}`,
2457
+ `- summary: ${nextPlan.summary || '-'}`,
2458
+ `- finalSummary: ${nextPlan.finalSummary || '-'}`
2459
+ ];
2460
+ const steps = Array.isArray(nextPlan.steps) ? nextPlan.steps : [];
2461
+ if (steps.length > 0) {
2462
+ lines.push('- steps:');
2463
+ for (let i = 0; i < Math.min(steps.length, 8); i += 1) {
2464
+ const step = steps[i];
2465
+ lines.push(` ${i + 1}. [${step.role || '-'}] ${step.title || '-'} :: ${step.task || '-'}`);
2466
+ }
2467
+ if (steps.length > 8) lines.push(` ... and ${steps.length - 8} more step(s)`);
2468
+ }
2469
+ return lines.join('\n');
2470
+ },
2471
+
2315
2472
  query_project_index(result) {
2316
2473
  if (!result || typeof result !== 'object') return String(result);
2317
2474
  const lines = [];
@@ -27,6 +27,7 @@ const BANNER = [
27
27
  ' ██████ ██████ ██████ ███████ ██ ██ ██ ██ ████ ██ '
28
28
  ];
29
29
  const BANNER_COLORS = ['magentaBright', 'redBright', 'yellowBright', 'cyanBright', 'magentaBright'];
30
+ const SPINNER_FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
30
31
  const ROLE_STYLES = {
31
32
  you: {
32
33
  accent: 'blueBright',
@@ -258,6 +259,18 @@ const TUI_COPY = {
258
259
  inputLocked: '删除确认进行中,请输入 yes 或 no',
259
260
  answerLabel: '确认输入(yes/no)',
260
261
  answerPlaceholder: 'yes 或 no'
262
+ },
263
+ planApproval: {
264
+ title: '确认执行计划?',
265
+ goalLabel: '目标',
266
+ summaryLabel: '摘要',
267
+ fileLabel: '文件',
268
+ prompt: '输入 /yes 执行,输入 /edit <反馈> 修改,输入 /reject 拒绝。',
269
+ invalidAnswer: '请输入 /yes、/edit <反馈> 或 /reject。',
270
+ missingFeedback: '请在 /edit 后提供反馈内容。',
271
+ inputLocked: '计划审批进行中,请在审批框输入 /yes、/edit 或 /reject',
272
+ answerLabel: '审批输入',
273
+ answerPlaceholder: '/yes | /edit <反馈> | /reject'
261
274
  }
262
275
  },
263
276
  en: {
@@ -415,6 +428,18 @@ const TUI_COPY = {
415
428
  inputLocked: 'Delete approval is active; type yes or no',
416
429
  answerLabel: 'Approval input (yes/no)',
417
430
  answerPlaceholder: 'yes or no'
431
+ },
432
+ planApproval: {
433
+ title: 'Approve this plan?',
434
+ goalLabel: 'Goal',
435
+ summaryLabel: 'Summary',
436
+ fileLabel: 'File',
437
+ prompt: 'Type /yes to execute, /edit <feedback> to revise, or /reject to discard.',
438
+ invalidAnswer: 'Please enter /yes, /edit <feedback>, or /reject.',
439
+ missingFeedback: 'Please provide feedback after /edit.',
440
+ inputLocked: 'Plan approval is active; type /yes, /edit <feedback>, or /reject',
441
+ answerLabel: 'Approval input',
442
+ answerPlaceholder: '/yes | /edit <feedback> | /reject'
418
443
  }
419
444
  }
420
445
  };
@@ -960,6 +985,38 @@ export function parseDeleteApprovalAnswer(value) {
960
985
  return normalized ? 'invalid' : 'empty';
961
986
  }
962
987
 
988
+ export function parsePlanApprovalAnswer(value) {
989
+ const raw = String(value || '').trim();
990
+ if (!raw) return { action: 'empty', command: '' };
991
+ const normalized = raw.toLowerCase();
992
+ if (normalized === '/yes' || normalized === 'yes') {
993
+ return { action: 'approve', command: '/yes' };
994
+ }
995
+ if (normalized === '/reject' || normalized === 'reject' || normalized === 'no') {
996
+ return { action: 'reject', command: '/reject' };
997
+ }
998
+ const editMatch = raw.match(/^\/?edit(?:\s+(.+))?$/i);
999
+ if (editMatch) {
1000
+ const feedback = String(editMatch[1] || '').trim();
1001
+ if (!feedback) return { action: 'missing_feedback', command: '' };
1002
+ return { action: 'edit', feedback, command: `/edit ${feedback}` };
1003
+ }
1004
+ return { action: 'invalid', command: '' };
1005
+ }
1006
+
1007
+ export function parsePendingPlanApprovalMessage(text = '') {
1008
+ const raw = String(text || '');
1009
+ if (!/^Plan approval is still pending\./i.test(raw.trim())) return null;
1010
+ const lines = raw.split(/\r?\n/);
1011
+ const out = { goal: '', summary: '', filePath: '' };
1012
+ for (const line of lines) {
1013
+ if (line.startsWith('Goal: ')) out.goal = line.slice('Goal: '.length).trim();
1014
+ else if (line.startsWith('Summary: ')) out.summary = line.slice('Summary: '.length).trim();
1015
+ else if (line.startsWith('Plan File: ')) out.filePath = line.slice('Plan File: '.length).trim();
1016
+ }
1017
+ return out;
1018
+ }
1019
+
963
1020
  export function formatDeleteApprovalLines(copy, request) {
964
1021
  const details = normalizeDeleteApprovalRequest(request);
965
1022
  if (!details) return [];
@@ -1021,7 +1078,9 @@ function getActivityDisplayParts(activity) {
1021
1078
  get_background_task: 'Task',
1022
1079
  stop_background_task: 'Stop',
1023
1080
  list_files: 'Glob',
1024
- update_todos: 'Update Todos'
1081
+ update_todos: 'Update Todos',
1082
+ read_plan: 'Read Plan',
1083
+ update_plan: 'Update Plan'
1025
1084
  };
1026
1085
  return {
1027
1086
  primary: labels[parsed.base] || parsed.base || 'Tool',
@@ -1114,7 +1173,7 @@ function stageDescriptor(inputStage, busy, runtimeStatus, copy) {
1114
1173
 
1115
1174
  function RuntimeStrip({ busy, runtimeStatus, loaderTick, copy }) {
1116
1175
  const status = normalizeRuntimeStatus(runtimeStatus, copy);
1117
- const dots = '●○○'.slice(loaderTick % 3, (loaderTick % 3) + 1) || '●';
1176
+ const spinnerChar = SPINNER_FRAMES[loaderTick % SPINNER_FRAMES.length];
1118
1177
  return h(
1119
1178
  Box,
1120
1179
  {
@@ -1126,7 +1185,7 @@ function RuntimeStrip({ busy, runtimeStatus, loaderTick, copy }) {
1126
1185
  },
1127
1186
  h(Text, { color: busy ? 'greenBright' : 'gray' }, busy ? copy.generic.live : copy.generic.idle),
1128
1187
  h(Text, { color: 'gray' }, ' '),
1129
- h(Text, { color: busy ? 'cyanBright' : 'gray' }, dots),
1188
+ h(Text, { color: busy ? 'cyanBright' : 'gray' }, spinnerChar),
1130
1189
  h(Text, { color: 'gray' }, ' '),
1131
1190
  h(Text, { color: busy ? 'white' : 'gray' }, status.title || copy.generic.waitingForInput)
1132
1191
  );
@@ -1168,7 +1227,7 @@ function ContextProgressMeter({ runtimeState, runtimeStatus, compact = false })
1168
1227
  Box,
1169
1228
  { justifyContent: 'flex-end', alignItems: 'center' },
1170
1229
  h(Text, { color: 'gray' }, '上下文 '),
1171
- h(Text, { color: statusColor }, `${Math.round(pct)}% `),
1230
+ h(Text, { color: activeColor }, `${Math.round(pct)}% `),
1172
1231
  h(
1173
1232
  Box,
1174
1233
  null,
@@ -1347,8 +1406,13 @@ export function parseAutoPlanSummaryMessage(text) {
1347
1406
  index: Number(stepMatch[1]),
1348
1407
  role: String(stepMatch[2] || '').trim().toLowerCase(),
1349
1408
  title: String(stepMatch[3] || '').trim(),
1409
+ task: '',
1350
1410
  status: 'pending'
1351
1411
  });
1412
+ } else if (/^-\s*task\s*:\s*/i.test(line) && parsed.planSteps.length > 0) {
1413
+ parsed.planSteps[parsed.planSteps.length - 1].task = line.replace(/^-\s*task\s*:\s*/i, '').trim();
1414
+ } else if (/^Next:\s*/i.test(line)) {
1415
+ inPlanSteps = false;
1352
1416
  } else {
1353
1417
  inPlanSteps = false;
1354
1418
  }
@@ -1708,6 +1772,7 @@ function PlanSummaryBubble({ msg, copy }) {
1708
1772
  summary.failed ? `${labels.fail} ${summary.failed}` : ''
1709
1773
  ].filter(Boolean);
1710
1774
  const shortFile = summary.filePath ? trimText(summary.filePath, 96) : '';
1775
+ const planSteps = Array.isArray(summary.planSteps) ? summary.planSteps : [];
1711
1776
 
1712
1777
  return h(
1713
1778
  Box,
@@ -1746,11 +1811,27 @@ function PlanSummaryBubble({ msg, copy }) {
1746
1811
  summary.planSummary
1747
1812
  ? h(
1748
1813
  Box,
1749
- { marginBottom: summary.approval || metaItems.length > 0 || summary.warningSteps || summary.failedSteps || shortFile ? 1 : 0, flexDirection: 'column' },
1814
+ { marginBottom: planSteps.length > 0 || summary.approval || metaItems.length > 0 || summary.warningSteps || summary.failedSteps || shortFile ? 1 : 0, flexDirection: 'column' },
1750
1815
  h(Text, { color: 'cyanBright' }, labels.plan),
1751
1816
  h(Text, { color: 'gray' }, summary.planSummary)
1752
1817
  )
1753
1818
  : null,
1819
+ planSteps.length > 0
1820
+ ? h(
1821
+ Box,
1822
+ { marginBottom: summary.approval || metaItems.length > 0 || summary.warningSteps || summary.failedSteps || shortFile ? 1 : 0, flexDirection: 'column' },
1823
+ h(Text, { color: 'cyanBright' }, labels.steps),
1824
+ ...planSteps.flatMap((step, idx) => {
1825
+ const roleTag = String(step?.role || '').trim().toUpperCase() || 'CODER';
1826
+ const titleText = String(step?.title || '-').trim() || '-';
1827
+ const taskText = String(step?.task || '').trim();
1828
+ const titleRow = h(Text, { key: `plan-step-title-${idx}`, color: 'gray' }, `${idx + 1}. [${roleTag}] ${titleText}`);
1829
+ if (!taskText) return [titleRow];
1830
+ const taskRow = h(Text, { key: `plan-step-task-${idx}`, color: 'gray' }, ` - task: ${taskText}`);
1831
+ return [titleRow, taskRow];
1832
+ })
1833
+ )
1834
+ : null,
1754
1835
  summary.approval
1755
1836
  ? h(
1756
1837
  Box,
@@ -2233,7 +2314,7 @@ export function renderMessageRow(msg, row, idx, loaderTick) {
2233
2314
  Box,
2234
2315
  { key: `row-tool-${msg.id}-${idx}` },
2235
2316
  h(Text, { color: 'gray' }, ' '),
2236
- h(Text, { color: dotColor }, '●'),
2317
+ h(Text, { color: dotColor }, row.status === 'running' ? SPINNER_FRAMES[loaderTick % SPINNER_FRAMES.length] : '●'),
2237
2318
  h(Text, { color: 'gray' }, ' '),
2238
2319
  h(Text, { color: textColor }, display.primary),
2239
2320
  h(Text, { color: 'gray' }, display.secondary),
@@ -2313,12 +2394,12 @@ export function renderMessageRow(msg, row, idx, loaderTick) {
2313
2394
  return null;
2314
2395
  }
2315
2396
  if (row.kind === 'status') {
2316
- const dots = '.'.repeat((loaderTick % 3) + 1);
2397
+ const spinnerChar = SPINNER_FRAMES[loaderTick % SPINNER_FRAMES.length];
2317
2398
  return h(
2318
2399
  Box,
2319
2400
  { key: `row-status-${msg.id}-${idx}`, marginTop: 1 },
2320
2401
  h(Text, { color: 'gray' }, ' '),
2321
- h(Text, { color: 'gray', dimColor: true }, `${row.text}${dots}`)
2402
+ h(Text, { color: 'gray', dimColor: true }, `${row.text} ${spinnerChar}`)
2322
2403
  );
2323
2404
  }
2324
2405
  if (row.kind === 'quote') {
@@ -2457,7 +2538,7 @@ export function moveSuggestionSelection(currentIndex, itemCount, direction, page
2457
2538
  return safeIndex;
2458
2539
  }
2459
2540
 
2460
- function MessageBubble({ msg, loaderTick, showToolDetails, rowWindow = null, contentWidth = 72, copy }) {
2541
+ const MessageBubble = React.memo(function MessageBubble({ msg, loaderTick, showToolDetails, rowWindow = null, contentWidth = 72, copy }) {
2461
2542
  if (msg?.planStrip) {
2462
2543
  return h(PlanStrip, { planState: msg.planState, copy });
2463
2544
  }
@@ -2509,7 +2590,13 @@ function MessageBubble({ msg, loaderTick, showToolDetails, rowWindow = null, con
2509
2590
  : null
2510
2591
  )
2511
2592
  );
2512
- }
2593
+ }, (prev, next) => {
2594
+ if (prev.msg === next.msg &&
2595
+ prev.showToolDetails === next.showToolDetails &&
2596
+ prev.contentWidth === next.contentWidth &&
2597
+ prev.copy === next.copy) return true;
2598
+ return false;
2599
+ });
2513
2600
 
2514
2601
  function MessageList({ messages, loaderTick, showToolDetails, contentWidth = 72, copy }) {
2515
2602
  return h(
@@ -2524,7 +2611,7 @@ function MessageList({ messages, loaderTick, showToolDetails, contentWidth = 72,
2524
2611
  h(MessageBubble, {
2525
2612
  key: message.id,
2526
2613
  msg: message,
2527
- loaderTick,
2614
+ loaderTick: message.loading ? loaderTick : 0,
2528
2615
  showToolDetails,
2529
2616
  contentWidth,
2530
2617
  copy
@@ -2706,7 +2793,32 @@ function InputBar({
2706
2793
  );
2707
2794
  }
2708
2795
 
2709
- function DeleteApprovalPanel({ request, inputValue, errorText, copy }) {
2796
+ function ApprovalCursorLine({ inputValue, placeholder, cursorVisible, accent }) {
2797
+ if (inputValue) {
2798
+ return h(
2799
+ Box,
2800
+ null,
2801
+ h(Text, { color: 'white' }, inputValue),
2802
+ h(
2803
+ Text,
2804
+ { color: cursorVisible ? 'black' : accent, backgroundColor: cursorVisible ? accent : undefined },
2805
+ ' '
2806
+ )
2807
+ );
2808
+ }
2809
+ return h(
2810
+ Box,
2811
+ null,
2812
+ h(
2813
+ Text,
2814
+ { color: cursorVisible ? 'black' : accent, backgroundColor: cursorVisible ? accent : undefined },
2815
+ ' '
2816
+ ),
2817
+ placeholder ? h(Text, { color: 'gray' }, placeholder) : null
2818
+ );
2819
+ }
2820
+
2821
+ function DeleteApprovalPanel({ request, inputValue, errorText, copy, cursorVisible }) {
2710
2822
  if (!request) return null;
2711
2823
  const details =
2712
2824
  request?.toolName === 'delete'
@@ -2735,7 +2847,48 @@ function DeleteApprovalPanel({ request, inputValue, errorText, copy }) {
2735
2847
  Box,
2736
2848
  { marginTop: 1 },
2737
2849
  h(Text, { color: 'redBright' }, `${copy.deleteApproval.answerLabel}: `),
2738
- h(Text, { color: inputValue ? 'white' : 'gray' }, inputValue || placeholder || ' ')
2850
+ h(ApprovalCursorLine, {
2851
+ inputValue,
2852
+ placeholder: placeholder || ' ',
2853
+ cursorVisible,
2854
+ accent: 'redBright'
2855
+ })
2856
+ ),
2857
+ errorText ? h(Text, { color: 'yellowBright' }, errorText) : null
2858
+ );
2859
+ }
2860
+
2861
+ function PlanApprovalPanel({ request, inputValue, errorText, copy, cursorVisible }) {
2862
+ if (!request) return null;
2863
+ const goal = String(request.goal || '').trim();
2864
+ const summary = String(request.summary || '').trim();
2865
+ const filePath = String(request.filePath || '').trim();
2866
+ const placeholder = String(copy.planApproval.answerPlaceholder || '').trim();
2867
+ return h(
2868
+ Box,
2869
+ {
2870
+ marginTop: 1,
2871
+ flexDirection: 'column',
2872
+ borderStyle: 'round',
2873
+ borderColor: 'yellowBright',
2874
+ paddingX: 1,
2875
+ paddingY: 0
2876
+ },
2877
+ h(Text, { color: 'yellowBright' }, copy.planApproval.title),
2878
+ goal ? h(Text, { color: 'white' }, `${copy.planApproval.goalLabel}: ${goal}`) : null,
2879
+ summary ? h(Text, { color: 'white' }, `${copy.planApproval.summaryLabel}: ${summary}`) : null,
2880
+ filePath ? h(Text, { color: 'gray' }, `${copy.planApproval.fileLabel}: ${filePath}`) : null,
2881
+ h(Text, { color: 'gray' }, copy.planApproval.prompt),
2882
+ h(
2883
+ Box,
2884
+ { marginTop: 1 },
2885
+ h(Text, { color: 'yellowBright' }, `${copy.planApproval.answerLabel}: `),
2886
+ h(ApprovalCursorLine, {
2887
+ inputValue,
2888
+ placeholder: placeholder || ' ',
2889
+ cursorVisible,
2890
+ accent: 'yellowBright'
2891
+ })
2739
2892
  ),
2740
2893
  errorText ? h(Text, { color: 'yellowBright' }, errorText) : null
2741
2894
  );
@@ -2825,6 +2978,10 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
2825
2978
  const [pendingDeleteApproval, setPendingDeleteApproval] = useState(null);
2826
2979
  const [deleteApprovalInput, setDeleteApprovalInput] = useState('');
2827
2980
  const [deleteApprovalError, setDeleteApprovalError] = useState('');
2981
+ const [pendingPlanApproval, setPendingPlanApproval] = useState(null);
2982
+ const [planApprovalInput, setPlanApprovalInput] = useState('');
2983
+ const [planApprovalError, setPlanApprovalError] = useState('');
2984
+ const approvalLockActive = Boolean(pendingDeleteApproval || pendingPlanApproval);
2828
2985
  const activeAssistantIdRef = useRef(null);
2829
2986
  const activeAssistantAutoSkillNamesRef = useRef([]);
2830
2987
  const streamedAssistantHandledRef = useRef(false);
@@ -3204,6 +3361,25 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
3204
3361
  resultVerified: '',
3205
3362
  resultNext: ''
3206
3363
  });
3364
+ setPendingPlanApproval({
3365
+ goal: parsedPlanSummary.planSummary || '',
3366
+ summary: parsedPlanSummary.finalSummary || parsedPlanSummary.planSummary || '',
3367
+ filePath: parsedPlanSummary.filePath || ''
3368
+ });
3369
+ setPlanApprovalInput('');
3370
+ setPlanApprovalError('');
3371
+ } else if (result.type === 'system') {
3372
+ const pendingMeta = parsePendingPlanApprovalMessage(result.text || '');
3373
+ if (pendingMeta) {
3374
+ setPendingPlanApproval({
3375
+ goal: pendingMeta.goal || '',
3376
+ summary: pendingMeta.summary || '',
3377
+ filePath: pendingMeta.filePath || ''
3378
+ });
3379
+ setPlanState((prev) => ({ ...prev, pendingApproval: true }));
3380
+ setPlanApprovalInput('');
3381
+ setPlanApprovalError('');
3382
+ }
3207
3383
  }
3208
3384
  setMessages((prev) => [
3209
3385
  ...prev,
@@ -3342,6 +3518,9 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
3342
3518
  setBusy(true);
3343
3519
  setInputStage('sending');
3344
3520
  setRuntimeStatus(makeStatus(copy.runtime.sendingToGateway, copy.runtime.preparingRequest, 'yellowBright'));
3521
+ setPendingPlanApproval(null);
3522
+ setPlanApprovalInput('');
3523
+ setPlanApprovalError('');
3345
3524
  setPlanState({
3346
3525
  current: 0,
3347
3526
  total: 0,
@@ -3623,6 +3802,9 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
3623
3802
  if (event?.type === 'plan:steps') {
3624
3803
  const planSteps = Array.isArray(event.steps) ? event.steps : [];
3625
3804
  if (planSteps.length > 0) {
3805
+ setPendingPlanApproval(null);
3806
+ setPlanApprovalInput('');
3807
+ setPlanApprovalError('');
3626
3808
  setPlanState((prev) => ({
3627
3809
  ...prev,
3628
3810
  total: planSteps.length,
@@ -3921,6 +4103,46 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
3921
4103
  return;
3922
4104
  }
3923
4105
 
4106
+ if (pendingPlanApproval) {
4107
+ if (key.return) {
4108
+ const parsed = parsePlanApprovalAnswer(planApprovalInput);
4109
+ if (parsed.action === 'approve' || parsed.action === 'reject' || parsed.action === 'edit') {
4110
+ setPendingPlanApproval(null);
4111
+ setPlanApprovalInput('');
4112
+ setPlanApprovalError('');
4113
+ runSubmission(parsed.command);
4114
+ } else if (parsed.action === 'missing_feedback') {
4115
+ setPlanApprovalError(copy.planApproval.missingFeedback);
4116
+ } else {
4117
+ setPlanApprovalError(copy.planApproval.invalidAnswer);
4118
+ }
4119
+ return;
4120
+ }
4121
+
4122
+ if (isBackspaceKey(value, key) || isDeleteKey(value, key)) {
4123
+ setPlanApprovalInput((prev) => prev.slice(0, -1));
4124
+ setPlanApprovalError('');
4125
+ return;
4126
+ }
4127
+
4128
+ if (isPrintableInput(value, key)) {
4129
+ setPlanApprovalInput((prev) => `${prev}${value}`);
4130
+ setPlanApprovalError('');
4131
+ return;
4132
+ }
4133
+
4134
+ if (key.ctrl && value === 'c') {
4135
+ if (busy && typeof runtime.abort === 'function') {
4136
+ runtime.abort();
4137
+ return;
4138
+ }
4139
+ exit();
4140
+ return;
4141
+ }
4142
+
4143
+ return;
4144
+ }
4145
+
3924
4146
  if (key.upArrow) {
3925
4147
  if (suggestionNav && commandSuggestions.length > 0) {
3926
4148
  setMenuIndex((prev) => moveSuggestionSelection(prev, commandSuggestions.length, 'up'));
@@ -4171,13 +4393,24 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
4171
4393
  }, []);
4172
4394
 
4173
4395
  useEffect(() => {
4174
- const hasLoadingMessage = messages.some((m) => m.loading);
4175
- if (!busy && !hasLoadingMessage) return () => {};
4396
+ if (!busy) return () => {};
4176
4397
  const timer = setInterval(() => {
4177
4398
  setLoaderTick((prev) => prev + 1);
4178
4399
  }, 500);
4179
4400
  return () => clearInterval(timer);
4180
- }, [busy, messages]);
4401
+ }, [busy]);
4402
+
4403
+ useEffect(() => {
4404
+ const pending = Boolean(runtimeState?.pendingPlanApproval);
4405
+ if (!pending) {
4406
+ setPendingPlanApproval(null);
4407
+ return;
4408
+ }
4409
+ // Startup/recovery fallback only while idle; do not resurrect the panel mid-execution.
4410
+ if (!busy) {
4411
+ setPendingPlanApproval((prev) => prev || { goal: '', summary: '', filePath: '' });
4412
+ }
4413
+ }, [runtimeState?.pendingPlanApproval, busy]);
4181
4414
 
4182
4415
  useEffect(() => {
4183
4416
  if (commandSuggestions.length === 0) {
@@ -4219,6 +4452,11 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
4219
4452
  activeUserMessageIdRef.current,
4220
4453
  activeAssistantIdRef.current
4221
4454
  );
4455
+ const activeApprovalLock = pendingDeleteApproval
4456
+ ? { text: copy.deleteApproval.inputLocked }
4457
+ : pendingPlanApproval
4458
+ ? { text: copy.planApproval.inputLocked }
4459
+ : null;
4222
4460
 
4223
4461
  return h(
4224
4462
  Box,
@@ -4251,7 +4489,15 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
4251
4489
  request: pendingDeleteApproval,
4252
4490
  inputValue: deleteApprovalInput,
4253
4491
  errorText: deleteApprovalError,
4254
- copy
4492
+ copy,
4493
+ cursorVisible
4494
+ }),
4495
+ h(PlanApprovalPanel, {
4496
+ request: pendingPlanApproval,
4497
+ inputValue: planApprovalInput,
4498
+ errorText: planApprovalError,
4499
+ copy,
4500
+ cursorVisible
4255
4501
  }),
4256
4502
  debugKeys
4257
4503
  ? h(
@@ -4266,8 +4512,8 @@ export function ChatApp({ runtime, sessionId, model, sdkProvider = 'openai-compa
4266
4512
  afterCursor,
4267
4513
  cursorVisible,
4268
4514
  busy,
4269
- disabled: Boolean(pendingDeleteApproval),
4270
- disabledText: pendingDeleteApproval ? copy.deleteApproval.inputLocked : '',
4515
+ disabled: Boolean(activeApprovalLock),
4516
+ disabledText: activeApprovalLock ? activeApprovalLock.text : '',
4271
4517
  inputStage,
4272
4518
  pendingQueueLength: pendingQueue.length,
4273
4519
  showToolDetails,