xibecode 1.0.8 → 1.2.2

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.
@@ -20,6 +20,7 @@ import { SPINNER_VERBS } from '../constants/spinnerVerbs.js';
20
20
  import { extractAtReferences, splitAtReferences } from 'xibecode-core';
21
21
  import { loadImageAttachment, mimeFromExtension } from '../utils/image-attachments.js';
22
22
  import { SessionManager } from 'xibecode-core';
23
+ import { AutoMemoryManager, HooksManager, SettingsManager as CoreSettingsManager } from 'xibecode-core';
23
24
  function isAbortLikeError(err) {
24
25
  if (!err || typeof err !== 'object')
25
26
  return false;
@@ -101,7 +102,7 @@ const HERO_LOGO = [
101
102
  const WORK_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
102
103
  /** How fast to advance OpenClaude-style spinner verbs (ms) */
103
104
  const WORK_VERB_ROTATE_MS = 2400;
104
- const QUICK_HELP = ['/help', '/mode', '/format', '/model', '/setup', '/config', '/donate', '/sponsor', '/clear', '/exit'];
105
+ const QUICK_HELP = ['/help', '/mode', '/format', '/model', '/setup', '/config', '/memory', '/hooks', '/donate', '/sponsor', '/clear', '/exit'];
105
106
  const CHAT_COMMANDS = [
106
107
  { name: '/help', description: 'Show available shortcuts and usage hints' },
107
108
  { name: '/mode', description: 'Switch agent mode from an interactive picker' },
@@ -110,6 +111,8 @@ const CHAT_COMMANDS = [
110
111
  { name: '/model', description: 'Fetch and switch available models for this provider' },
111
112
  { name: '/setup', description: 'Guided setup (set API key, then pick provider/model)' },
112
113
  { name: '/config', description: 'Show current config and quick config hints' },
114
+ { name: '/memory', description: 'Show auto-memories for this project' },
115
+ { name: '/hooks', description: 'Show registered lifecycle hooks' },
113
116
  { name: '/donate', description: 'Open the donation page in your browser' },
114
117
  { name: '/sponsor', description: 'Open the sponsorship page in your browser' },
115
118
  { name: '/exit', description: 'Exit the interactive chat session' },
@@ -232,6 +235,7 @@ function XibeCodeChatApp(props) {
232
235
  const [configProviderIndex, setConfigProviderIndex] = useState(0);
233
236
  const [configCostModePickerOpen, setConfigCostModePickerOpen] = useState(false);
234
237
  const [configCostModeIndex, setConfigCostModeIndex] = useState(0);
238
+ const [questionsState, setQuestionsState] = useState(null);
235
239
  const [workSpinnerFrame, setWorkSpinnerFrame] = useState(0);
236
240
  const [workVerbIndex, setWorkVerbIndex] = useState(0);
237
241
  const nextLineIdRef = useRef(1);
@@ -279,6 +283,11 @@ function XibeCodeChatApp(props) {
279
283
  setActiveMode(nextMode);
280
284
  });
281
285
  }, [props]);
286
+ useEffect(() => {
287
+ props.registerQuestionsSink?.((qs) => {
288
+ setQuestionsState({ questions: qs, currentIndex: 0, answers: {}, selectedOptionIndex: 0, isTypingCustom: false });
289
+ });
290
+ }, [props]);
282
291
  const abortControllerRef = useRef(null);
283
292
  const abortReasonRef = useRef('none');
284
293
  const queuedPromptRef = useRef(null);
@@ -336,13 +345,14 @@ function XibeCodeChatApp(props) {
336
345
  if (!isRunning)
337
346
  return;
338
347
  const id = setInterval(() => {
339
- // Silent means: no visible output (tool_call/tool_result/stream_text/response) for 90s.
340
- if (Date.now() - lastVisibleOutputAtRef.current > 90_000) {
348
+ // Silent means: no visible output (tool_call/tool_result/stream_text/response) for 180s.
349
+ // Some models (DeepSeek, etc.) can take a long time for their first token during complex tasks.
350
+ if (Date.now() - lastVisibleOutputAtRef.current > 180_000) {
341
351
  if (abortControllerRef.current && abortReasonRef.current === 'none') {
342
352
  abortReasonRef.current = 'watchdog';
343
353
  pushLine({
344
354
  type: 'info',
345
- text: `No output for 90s — restarting (attempt ${Math.min(restartAttemptsRef.current + 1, 2)}/2)…`,
355
+ text: `No output for 180s — restarting (attempt ${Math.min(restartAttemptsRef.current + 1, 2)}/2)…`,
346
356
  });
347
357
  abortControllerRef.current.abort();
348
358
  }
@@ -471,10 +481,79 @@ function XibeCodeChatApp(props) {
471
481
  text: `Mode switched to ${nextMode}`,
472
482
  });
473
483
  }, [activeMode, props, pushLine]);
484
+ const applyQuestionAnswer = useCallback(async (state, answer) => {
485
+ const { questions, currentIndex, answers } = state;
486
+ const currentQ = questions[currentIndex];
487
+ const newAnswers = { ...answers, [currentQ.id]: answer };
488
+ const nextIndex = currentIndex + 1;
489
+ if (nextIndex < questions.length) {
490
+ // More questions - advance with reset selection
491
+ setQuestionsState({
492
+ questions,
493
+ currentIndex: nextIndex,
494
+ answers: newAnswers,
495
+ selectedOptionIndex: 0,
496
+ isTypingCustom: false,
497
+ });
498
+ setInput('');
499
+ pushLine({ type: 'info', text: ` ${currentIndex + 1}. ${currentQ.question} → ${answer}` });
500
+ }
501
+ else {
502
+ // All questions answered - submit
503
+ setQuestionsState(null);
504
+ setInput('');
505
+ pushLine({ type: 'info', text: ` ${currentIndex + 1}. ${currentQ.question} → ${answer}` });
506
+ const answersText = questions
507
+ .map((q, i) => `${i + 1}. ${q.question}\n Answer: ${newAnswers[q.id] || '(skipped)'}`)
508
+ .join('\n');
509
+ pushLine({ type: 'info', text: 'Answers submitted. Continuing...' });
510
+ const answersPrompt = `Here are my answers to your questions:\n\n${answersText}`;
511
+ currentPromptRef.current = answersPrompt;
512
+ abortReasonRef.current = 'none';
513
+ lastVisibleOutputAtRef.current = Date.now();
514
+ abortControllerRef.current = new AbortController();
515
+ pushLine({ type: 'user', text: answersPrompt });
516
+ sessionMessagesRef.current.push({ role: 'user', content: answersPrompt });
517
+ setIsRunning(true);
518
+ try {
519
+ const startedAt = Date.now();
520
+ const stats = await props.runPrompt(answersPrompt, pushLine, {
521
+ signal: abortControllerRef.current.signal,
522
+ onVisibleOutput: () => {
523
+ lastVisibleOutputAtRef.current = Date.now();
524
+ },
525
+ });
526
+ const elapsedMs = Date.now() - startedAt;
527
+ const seconds = (elapsedMs / 1000).toFixed(1);
528
+ pushLine({
529
+ type: 'info',
530
+ text: `Done in ${seconds}s` + (stats.costLabel ? ` · cost ${stats.costLabel}` : ''),
531
+ });
532
+ }
533
+ finally {
534
+ abortControllerRef.current = null;
535
+ currentPromptRef.current = null;
536
+ setIsRunning(false);
537
+ }
538
+ }
539
+ }, [props, pushLine]);
540
+ // Called from useInput when a question option is selected via arrows+Enter
541
+ const handleQuestionAnswer = useCallback((state, answer) => {
542
+ applyQuestionAnswer(state, answer);
543
+ }, [applyQuestionAnswer]);
474
544
  const onSubmit = useCallback(async (value) => {
475
545
  const trimmed = value.trim();
476
546
  if (!trimmed)
477
547
  return;
548
+ // Handle custom-typing mode for questions (Enter after typing custom answer)
549
+ if (questionsState && questionsState.isTypingCustom) {
550
+ await applyQuestionAnswer(questionsState, trimmed);
551
+ return;
552
+ }
553
+ // If questions are active but not in typing mode, ignore (use arrows + Enter)
554
+ if (questionsState) {
555
+ return;
556
+ }
478
557
  if (isRunning) {
479
558
  queuedPromptRef.current = trimmed;
480
559
  setInput('');
@@ -632,6 +711,16 @@ function XibeCodeChatApp(props) {
632
711
  startSetupWizard();
633
712
  return;
634
713
  }
714
+ if (resolvedInput === '/memory' || resolvedInput.startsWith('/memory ')) {
715
+ const subcmd = resolvedInput.replace('/memory', '').trim().toLowerCase();
716
+ props.onMemoryCommand?.(subcmd || 'list', pushLine);
717
+ return;
718
+ }
719
+ if (resolvedInput === '/hooks' || resolvedInput.startsWith('/hooks ')) {
720
+ const subcmd = resolvedInput.replace('/hooks', '').trim().toLowerCase();
721
+ props.onHooksCommand?.(subcmd || 'list', pushLine);
722
+ return;
723
+ }
635
724
  if (resolvedInput === '/format' || resolvedInput.startsWith('/format ')) {
636
725
  const arg = resolvedInput.replace(/^\/format\s*/i, '').trim().toLowerCase();
637
726
  if (!arg) {
@@ -857,6 +946,72 @@ function XibeCodeChatApp(props) {
857
946
  exit();
858
947
  return;
859
948
  }
949
+ // Interactive questions: arrow key navigation
950
+ if (questionsState && !questionsState.isTypingCustom) {
951
+ const q = questionsState.questions[questionsState.currentIndex];
952
+ const totalOptions = q.options.length + (q.hasOther !== false ? 1 : 0);
953
+ if (key.upArrow) {
954
+ setQuestionsState({
955
+ ...questionsState,
956
+ selectedOptionIndex: questionsState.selectedOptionIndex <= 0
957
+ ? totalOptions - 1
958
+ : questionsState.selectedOptionIndex - 1,
959
+ });
960
+ return;
961
+ }
962
+ if (key.downArrow) {
963
+ setQuestionsState({
964
+ ...questionsState,
965
+ selectedOptionIndex: questionsState.selectedOptionIndex >= totalOptions - 1
966
+ ? 0
967
+ : questionsState.selectedOptionIndex + 1,
968
+ });
969
+ return;
970
+ }
971
+ // Enter on a highlighted option: if "type yourself" is selected, switch to typing mode
972
+ if (key.return) {
973
+ const idx = questionsState.selectedOptionIndex;
974
+ const isOtherOption = idx === q.options.length && q.hasOther !== false;
975
+ if (isOtherOption) {
976
+ setQuestionsState({ ...questionsState, isTypingCustom: true });
977
+ setInput('');
978
+ return;
979
+ }
980
+ // Picked a concrete option — resolve and submit
981
+ if (idx < q.options.length) {
982
+ const answer = q.options[idx].label;
983
+ handleQuestionAnswer(questionsState, answer);
984
+ return;
985
+ }
986
+ }
987
+ // Escape to cancel questions
988
+ if (key.escape) {
989
+ setQuestionsState(null);
990
+ pushLine({ type: 'info', text: 'Questions cancelled.' });
991
+ return;
992
+ }
993
+ // If not a special key and not typing custom, ignore (user must use arrows + Enter)
994
+ if (!key.return && !key.escape && !key.upArrow && !key.downArrow && !key.ctrl && !key.meta) {
995
+ return;
996
+ }
997
+ }
998
+ // If in custom-typing mode for a question, handle Enter to submit
999
+ if (questionsState && questionsState.isTypingCustom) {
1000
+ if (key.return) {
1001
+ const answer = input.trim();
1002
+ if (!answer)
1003
+ return;
1004
+ handleQuestionAnswer(questionsState, answer);
1005
+ return;
1006
+ }
1007
+ if (key.escape) {
1008
+ // Go back to option selection
1009
+ setQuestionsState({ ...questionsState, isTypingCustom: false });
1010
+ setInput('');
1011
+ return;
1012
+ }
1013
+ // Let normal TextInput handle typing — don't intercept
1014
+ }
860
1015
  if (isRunning && key.escape) {
861
1016
  if (abortControllerRef.current && abortReasonRef.current === 'none') {
862
1017
  abortReasonRef.current = 'user';
@@ -1235,7 +1390,27 @@ function XibeCodeChatApp(props) {
1235
1390
  : 'OpenAI chat', ")"] })] })] })] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "suggestion", children: "\u25C8 cloud" }), _jsx(Text, { color: "inactive", children: " Ready \u2014 type " }), _jsx(Text, { color: "claude", children: "/help" }), _jsx(Text, { color: "inactive", children: " to begin" })] }), _jsxs(Text, { color: "inactive", children: ["xibecode ", _jsxs(Text, { color: "claude", children: ["v", APP_VERSION] })] }), _jsx(Text, { color: "subtle", children: '─'.repeat(98) }), _jsx(Text, { color: "inactive", children: "Agent transcript" })] }, item.id));
1236
1391
  }
1237
1392
  return (_jsx(React.Fragment, { children: item.type === 'assistant' ? (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { bold: true, color: prefixColorKey('assistant'), children: [prefixForType('assistant'), ":"] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: _jsx(AssistantMarkdown, { content: item.text }) })] })) : (_jsxs(Text, { children: [_jsxs(Text, { bold: true, color: prefixColorKey(item.type), children: [prefixForType(item.type), ":", ' '] }), _jsx(Text, { color: lineColorKey(item.type), children: item.text })] })) }, item.id));
1238
- } }), !hasChatContent && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: "inactive", children: "(send a message to start)" }) })), _jsx(Text, { color: "subtle", children: divider }), isRunning && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "claudeBlue_FOR_SYSTEM_SPINNER", flexDirection: "column", children: _jsxs(Text, { wrap: "wrap", children: [_jsxs(Text, { bold: true, color: "claudeBlue_FOR_SYSTEM_SPINNER", children: [WORK_SPINNER_FRAMES[workSpinnerFrame], ' '] }), _jsx(Text, { bold: true, color: "briefLabelClaude", children: workVerbPhrase })] }) })), _jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "claude", paddingX: 1, children: [_jsx(Text, { color: "claude", children: '> ' }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: isRunning ? 'Waiting for response…' : 'Message XibeCode…' })] }), isSlashMode && (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "suggestion", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "suggestion", bold: true, children: "Commands" }), filteredCommands.length === 0 ? (_jsxs(Text, { color: "inactive", children: ["No commands match \"", input, "\""] })) : (filteredCommands.map((command, index) => (_jsx(React.Fragment, { children: _jsxs(Text, { children: [_jsx(Text, { color: index === selectedCommandIndex ? 'claude' : 'inactive', children: index === selectedCommandIndex ? '▸ ' : ' ' }), _jsx(Text, { bold: true, color: index === selectedCommandIndex ? 'claude' : 'text', children: command.name }), _jsxs(Text, { color: "inactive", children: [" \u2014 ", command.description] })] }) }, command.name)))), _jsx(Text, { color: "subtle", children: "Use \u2191/\u2193 to navigate, Tab to autocomplete." })] })), modelPickerOpen && (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "claude", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "claude", children: "Select model" }), isModelListLoading ? (_jsx(Text, { color: "inactive", children: "Loading models from provider..." })) : filteredModels.length === 0 ? (_jsx(Text, { color: "inactive", children: "No models matched current filter." })) : (visibleModelOptions.map((modelName, index) => {
1393
+ } }), !hasChatContent && (_jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Text, { color: "inactive", children: "(send a message to start)" }) })), _jsx(Text, { color: "subtle", children: divider }), isRunning && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "claudeBlue_FOR_SYSTEM_SPINNER", flexDirection: "column", children: _jsxs(Text, { wrap: "wrap", children: [_jsxs(Text, { bold: true, color: "claudeBlue_FOR_SYSTEM_SPINNER", children: [WORK_SPINNER_FRAMES[workSpinnerFrame], ' '] }), _jsx(Text, { bold: true, color: "briefLabelClaude", children: workVerbPhrase })] }) })), questionsState && (() => {
1394
+ const { questions, currentIndex, selectedOptionIndex, isTypingCustom } = questionsState;
1395
+ const q = questions[currentIndex];
1396
+ const optLetters = 'abcdefghij';
1397
+ const totalOptions = q.options.length + (q.hasOther !== false ? 1 : 0);
1398
+ if (isTypingCustom) {
1399
+ return (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "green", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", children: ["Question ", currentIndex + 1, "/", questions.length, " \u2014 Type your answer"] }), _jsx(Text, { color: "text", children: q.question }), _jsx(Text, { color: "inactive", dimColor: true, children: "Press Esc to go back to options" })] }));
1400
+ }
1401
+ return (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["Question ", currentIndex + 1, "/", questions.length] }), _jsxs(Text, { color: "text", children: [q.question, q.allowMultiple ? ' (select all that apply)' : ''] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [q.options.map((o, j) => {
1402
+ const isSelected = j === selectedOptionIndex;
1403
+ return (_jsxs(Text, { children: [_jsx(Text, { bold: true, color: isSelected ? 'green' : 'inactive', children: isSelected ? ' ▸ ' : ' ' }), _jsxs(Text, { bold: true, color: isSelected ? 'green' : 'yellow', children: [optLetters[j], ")"] }), _jsxs(Text, { color: isSelected ? 'green' : 'text', children: [" ", o.label] })] }, o.id));
1404
+ }), q.hasOther !== false && (() => {
1405
+ const otherIdx = q.options.length;
1406
+ const isSelected = otherIdx === selectedOptionIndex;
1407
+ return (_jsxs(Text, { children: [_jsx(Text, { bold: true, color: isSelected ? 'green' : 'inactive', children: isSelected ? ' ▸ ' : ' ' }), _jsxs(Text, { bold: true, color: isSelected ? 'green' : 'yellow', children: [optLetters[otherIdx], ")"] }), _jsx(Text, { color: isSelected ? 'green' : 'inactive', children: " type yourself" })] }));
1408
+ })()] }), _jsx(Text, { color: "inactive", dimColor: true, children: "\u2191/\u2193 to navigate, Enter to select, Esc to cancel" })] }));
1409
+ })(), _jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: questionsState ? (questionsState.isTypingCustom ? 'green' : 'yellow') : 'claude', paddingX: 1, children: [_jsx(Text, { color: questionsState ? (questionsState.isTypingCustom ? 'green' : 'yellow') : 'claude', children: '> ' }), _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: questionsState
1410
+ ? questionsState.isTypingCustom
1411
+ ? `Type your answer for Q${questionsState.currentIndex + 1} and press Enter`
1412
+ : `Use ↑/↓ and Enter to pick an option (Q${questionsState.currentIndex + 1}/${questionsState.questions.length})`
1413
+ : isRunning ? 'Waiting for response…' : 'Message XibeCode…' })] }), isSlashMode && (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "suggestion", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: "suggestion", bold: true, children: "Commands" }), filteredCommands.length === 0 ? (_jsxs(Text, { color: "inactive", children: ["No commands match \"", input, "\""] })) : (filteredCommands.map((command, index) => (_jsx(React.Fragment, { children: _jsxs(Text, { children: [_jsx(Text, { color: index === selectedCommandIndex ? 'claude' : 'inactive', children: index === selectedCommandIndex ? '▸ ' : ' ' }), _jsx(Text, { bold: true, color: index === selectedCommandIndex ? 'claude' : 'text', children: command.name }), _jsxs(Text, { color: "inactive", children: [" \u2014 ", command.description] })] }) }, command.name)))), _jsx(Text, { color: "subtle", children: "Use \u2191/\u2193 to navigate, Tab to autocomplete." })] })), modelPickerOpen && (_jsxs(Box, { marginTop: 1, borderStyle: "round", borderColor: "claude", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, color: "claude", children: "Select model" }), isModelListLoading ? (_jsx(Text, { color: "inactive", children: "Loading models from provider..." })) : filteredModels.length === 0 ? (_jsx(Text, { color: "inactive", children: "No models matched current filter." })) : (visibleModelOptions.map((modelName, index) => {
1239
1414
  const absoluteIndex = modelPickerStart + index;
1240
1415
  const isSelected = absoluteIndex === selectedModelIndex;
1241
1416
  return (_jsx(React.Fragment, { children: _jsxs(Text, { children: [_jsx(Text, { color: isSelected ? 'claude' : 'inactive', children: isSelected ? '▸ ' : ' ' }), _jsx(Text, { color: isSelected ? 'claude' : 'text', children: modelName })] }) }, modelName));
@@ -1397,15 +1572,36 @@ export async function launchClaudeStyleChat(options) {
1397
1572
  }
1398
1573
  activeAgent.removeAllListeners('event');
1399
1574
  let streamedBuffer = '';
1575
+ let streamFlushTimer = null;
1576
+ const flushStreamedBuffer = () => {
1577
+ if (streamedBuffer.trim()) {
1578
+ onLine({ type: 'assistant', text: streamedBuffer.trim() });
1579
+ streamedBuffer = '';
1580
+ }
1581
+ };
1400
1582
  activeAgent.on('event', (event) => {
1401
1583
  switch (event.type) {
1402
1584
  case 'thinking':
1585
+ // Flush any pending streamed text before showing thinking indicator
1586
+ flushStreamedBuffer();
1587
+ if (streamFlushTimer) {
1588
+ clearTimeout(streamFlushTimer);
1589
+ streamFlushTimer = null;
1590
+ }
1591
+ // Thinking messages are visible output — reset the watchdog timer
1592
+ opts?.onVisibleOutput?.();
1403
1593
  onLine({
1404
1594
  type: 'info',
1405
1595
  text: event.data?.message || 'Thinking…',
1406
1596
  });
1407
1597
  break;
1408
1598
  case 'tool_call': {
1599
+ // Flush any pending streamed text before showing tool call
1600
+ flushStreamedBuffer();
1601
+ if (streamFlushTimer) {
1602
+ clearTimeout(streamFlushTimer);
1603
+ streamFlushTimer = null;
1604
+ }
1409
1605
  opts?.onVisibleOutput?.();
1410
1606
  const name = String(event.data?.name ?? 'tool');
1411
1607
  const input = event.data?.input;
@@ -1417,6 +1613,12 @@ export async function launchClaudeStyleChat(options) {
1417
1613
  break;
1418
1614
  }
1419
1615
  case 'tool_result': {
1616
+ // Flush any pending streamed text before showing tool result
1617
+ flushStreamedBuffer();
1618
+ if (streamFlushTimer) {
1619
+ clearTimeout(streamFlushTimer);
1620
+ streamFlushTimer = null;
1621
+ }
1420
1622
  opts?.onVisibleOutput?.();
1421
1623
  const name = String(event.data?.name ?? 'tool');
1422
1624
  const result = event.data?.result;
@@ -1441,12 +1643,30 @@ export async function launchClaudeStyleChat(options) {
1441
1643
  case 'stream_text':
1442
1644
  opts?.onVisibleOutput?.();
1443
1645
  streamedBuffer += event.data?.text || '';
1646
+ // Accumulate streamed text and flush periodically or when buffer is large.
1647
+ // Use a longer interval (2s) to batch more text into fewer "XibeCode:" blocks,
1648
+ // reducing the fragmented multi-prefix display seen with fast models.
1649
+ if (streamedBuffer.length > 1500) {
1650
+ // Large buffer — flush immediately to avoid memory buildup
1651
+ if (streamFlushTimer) {
1652
+ clearTimeout(streamFlushTimer);
1653
+ streamFlushTimer = null;
1654
+ }
1655
+ flushStreamedBuffer();
1656
+ }
1657
+ else if (!streamFlushTimer) {
1658
+ streamFlushTimer = setTimeout(() => {
1659
+ streamFlushTimer = null;
1660
+ flushStreamedBuffer();
1661
+ }, 2000);
1662
+ }
1444
1663
  break;
1445
1664
  case 'stream_end':
1446
- if (streamedBuffer.trim()) {
1447
- onLine({ type: 'assistant', text: streamedBuffer.trim() });
1665
+ if (streamFlushTimer) {
1666
+ clearTimeout(streamFlushTimer);
1667
+ streamFlushTimer = null;
1448
1668
  }
1449
- streamedBuffer = '';
1669
+ flushStreamedBuffer();
1450
1670
  break;
1451
1671
  case 'response':
1452
1672
  opts?.onVisibleOutput?.();
@@ -1473,6 +1693,20 @@ export async function launchClaudeStyleChat(options) {
1473
1693
  }
1474
1694
  break;
1475
1695
  }
1696
+ case 'plan_ready':
1697
+ onLine({
1698
+ type: 'info',
1699
+ text: 'Plan written to implementations.md. Say "build" to implement it, or ask for edits.',
1700
+ });
1701
+ break;
1702
+ case 'questions': {
1703
+ const qs = event.data?.questions;
1704
+ if (qs && qs.length > 0) {
1705
+ // Push questions to the UI component via the sink
1706
+ questionsSink?.(qs);
1707
+ }
1708
+ break;
1709
+ }
1476
1710
  default:
1477
1711
  break;
1478
1712
  }
@@ -1481,6 +1715,22 @@ export async function launchClaudeStyleChat(options) {
1481
1715
  images: opts?.images,
1482
1716
  signal: opts?.signal,
1483
1717
  });
1718
+ // Write the latest messages to the transcript incrementally.
1719
+ // The agent's run() method appends to this.messages; we persist
1720
+ // the user prompt and assistant response as separate transcript entries.
1721
+ const latestMessages = activeAgent.getMessages();
1722
+ if (latestMessages.length > 0) {
1723
+ // Write the user prompt that triggered this turn
1724
+ const userMsg = latestMessages[latestMessages.length - 2];
1725
+ if (userMsg?.role === 'user') {
1726
+ await activeAgent.transcriptUserMessage(userMsg);
1727
+ }
1728
+ // Write the assistant response
1729
+ const assistantMsg = latestMessages[latestMessages.length - 1];
1730
+ if (assistantMsg?.role === 'assistant') {
1731
+ await activeAgent.transcriptAssistantMessage(assistantMsg);
1732
+ }
1733
+ }
1484
1734
  await onMessagesUpdate(activeAgent.getMessages());
1485
1735
  activeMode = activeAgent.getMode();
1486
1736
  toolExecutor.setMode(activeMode);
@@ -1527,6 +1777,11 @@ export async function launchClaudeStyleChat(options) {
1527
1777
  const registerModeSink = (sink) => {
1528
1778
  modeSink = sink;
1529
1779
  };
1780
+ // Sink for pushing interactive questions from the agent to the UI component.
1781
+ let questionsSink = null;
1782
+ const registerQuestionsSink = (sink) => {
1783
+ questionsSink = sink;
1784
+ };
1530
1785
  const formatUnknownError = (err) => {
1531
1786
  if (err instanceof Error)
1532
1787
  return err.stack || err.message;
@@ -1558,13 +1813,27 @@ export async function launchClaudeStyleChat(options) {
1558
1813
  cwd: process.cwd(),
1559
1814
  });
1560
1815
  currentSessionId = currentSession.id;
1816
+ // Initialize transcript persistence for the agent
1817
+ activeAgent?.initTranscript(currentSessionId, process.cwd());
1561
1818
  if (options.initialMessages && options.initialMessages.length > 0) {
1562
1819
  currentSession.messages = options.initialMessages;
1563
- await sessionManager.saveSession(currentSession);
1820
+ // Write initial messages to transcript
1821
+ for (const msg of options.initialMessages) {
1822
+ if (msg.role === 'user') {
1823
+ await activeAgent?.transcriptUserMessage(msg);
1824
+ }
1825
+ else if (msg.role === 'assistant') {
1826
+ await activeAgent?.transcriptAssistantMessage(msg);
1827
+ }
1828
+ }
1564
1829
  }
1565
1830
  }
1566
1831
  else {
1567
1832
  currentSession = await sessionManager.loadSession(currentSessionId);
1833
+ // Initialize transcript persistence for resumed session
1834
+ if (currentSession) {
1835
+ activeAgent?.initTranscript(currentSessionId, currentSession.cwd);
1836
+ }
1568
1837
  }
1569
1838
  if (currentSession?.messages?.length) {
1570
1839
  activeAgent?.setMessages(currentSession.messages);
@@ -1574,6 +1843,7 @@ export async function launchClaudeStyleChat(options) {
1574
1843
  }
1575
1844
  const onSessionCreated = (sessionId) => {
1576
1845
  currentSessionId = sessionId;
1846
+ activeAgent?.initTranscript(sessionId, process.cwd());
1577
1847
  };
1578
1848
  const onMessagesUpdate = async (messages) => {
1579
1849
  if (currentSessionId && currentSession) {
@@ -1587,12 +1857,86 @@ export async function launchClaudeStyleChat(options) {
1587
1857
  }
1588
1858
  }
1589
1859
  }
1590
- await sessionManager.saveSession(currentSession);
1860
+ // Use JSONL transcript persistence instead of monolithic JSON save.
1861
+ // Each message was already written to the transcript incrementally
1862
+ // via transcriptUserMessage/transcriptAssistantMessage in the chat loop.
1863
+ // Here we just update the title metadata.
1864
+ try {
1865
+ await sessionManager.saveSession(currentSession);
1866
+ }
1867
+ catch {
1868
+ // Transcript writes are best-effort; don't block the chat loop
1869
+ }
1591
1870
  }
1592
1871
  };
1593
1872
  const root = createRoot({ exitOnCtrlC: true });
1873
+ // ── Memory & Hooks slash-command handlers ──
1874
+ const autoMemManager = new AutoMemoryManager({ cwd: process.cwd() });
1875
+ const coreSettingsManager = new CoreSettingsManager({ cwd: process.cwd() });
1876
+ const hooksMgr = new HooksManager(coreSettingsManager);
1877
+ await hooksMgr.loadFromSettingsManager().catch(() => { });
1878
+ const onMemoryCommand = (subcmd, pushLine) => {
1879
+ void (async () => {
1880
+ try {
1881
+ if (subcmd === 'list' || subcmd === '') {
1882
+ const memories = await autoMemManager.listMemories();
1883
+ if (memories.length === 0) {
1884
+ pushLine({ type: 'info', text: 'No memories found for this project. Memories are auto-extracted as you chat.' });
1885
+ return;
1886
+ }
1887
+ pushLine({ type: 'info', text: `Found ${memories.length} memory/memories:` });
1888
+ for (const mem of memories.slice(0, 10)) {
1889
+ const tags = mem.frontmatter.tags?.length ? ` [${mem.frontmatter.tags.join(', ')}]` : '';
1890
+ pushLine({ type: 'info', text: ` ${mem.frontmatter.type}${tags}: ${mem.content.trim().slice(0, 80)}${mem.content.length > 80 ? '...' : ''}` });
1891
+ }
1892
+ if (memories.length > 10) {
1893
+ pushLine({ type: 'info', text: ` ... and ${memories.length - 10} more. Use "xc memory list" to see all.` });
1894
+ }
1895
+ }
1896
+ else if (subcmd === 'dream') {
1897
+ pushLine({ type: 'info', text: 'Running dream consolidation...' });
1898
+ const result = await autoMemManager.dream();
1899
+ pushLine({ type: 'info', text: `Dream complete: created=${result.created}, merged=${result.merged}, pruned=${result.pruned}` });
1900
+ }
1901
+ else if (subcmd === 'path') {
1902
+ pushLine({ type: 'info', text: `Memory dir: ${autoMemManager.getMemoryDir()}` });
1903
+ }
1904
+ else {
1905
+ pushLine({ type: 'info', text: 'Usage: /memory [list|dream|path]. Default: list' });
1906
+ }
1907
+ }
1908
+ catch (err) {
1909
+ pushLine({ type: 'error', text: `Memory error: ${err?.message || err}` });
1910
+ }
1911
+ })();
1912
+ };
1913
+ const onHooksCommand = (subcmd, pushLine) => {
1914
+ const allHooks = hooksMgr.getAllHooks();
1915
+ const flatList = [];
1916
+ for (const [event, hooks] of allHooks) {
1917
+ for (const hook of hooks) {
1918
+ flatList.push({ event, config: hook.config, matcher: hook.matcher });
1919
+ }
1920
+ }
1921
+ if (subcmd === 'list' || subcmd === '') {
1922
+ if (flatList.length === 0) {
1923
+ pushLine({ type: 'info', text: 'No hooks configured. Use "xc hooks add" or edit ~/.xibecode/settings.json' });
1924
+ return;
1925
+ }
1926
+ pushLine({ type: 'info', text: `Registered hooks (${flatList.length}):` });
1927
+ for (const hook of flatList.slice(0, 10)) {
1928
+ const type = 'command' in hook.config ? 'command' : 'prompt' in hook.config ? 'prompt' : 'agent' in hook.config ? 'agent' : 'http' in hook.config ? 'http' : 'function';
1929
+ const value = hook.config.command || hook.config.prompt || hook.config.agent || hook.config.http || hook.config.url || '(fn)';
1930
+ const matcher = hook.matcher ? ` matcher="${hook.matcher}"` : '';
1931
+ pushLine({ type: 'info', text: ` ${hook.event}${matcher} -> ${type}: ${value}` });
1932
+ }
1933
+ }
1934
+ else {
1935
+ pushLine({ type: 'info', text: 'Usage: /hooks [list]. Default: list. Use "xc hooks" CLI for full management.' });
1936
+ }
1937
+ };
1594
1938
  try {
1595
- await renderAndRun(root, _jsx(XibeCodeChatApp, { model: model, initialMode: activeMode, provider: provider, baseUrl: baseUrl, needsFirstRunSetup: needsFirstRunSetup, defaultModel: model, modeOptions: modeOptions, initialRequestFormat: wireFormat, customProviderFormat: customProviderFormat, profile: options.profile, sessionId: currentSessionId, initialMessages: options.initialMessages, runPrompt: runPrompt, listBackgroundTasks: listBackgroundTasks, checkBackgroundTask: checkBackgroundTask, onUiLine: appendLogLine, registerUiSink: registerUiSink, registerModeSink: registerModeSink, loadModels: loadModels, onModelChange: onModelChange, onModeChange: onModeChange, onWireFormatChange: onWireFormatChange, onSessionCreated: onSessionCreated, onMessagesUpdate: onMessagesUpdate, getCurrentMessages: () => activeAgent?.getMessages() ?? currentSession?.messages ?? [] }));
1939
+ await renderAndRun(root, _jsx(XibeCodeChatApp, { model: model, initialMode: activeMode, provider: provider, baseUrl: baseUrl, needsFirstRunSetup: needsFirstRunSetup, defaultModel: model, modeOptions: modeOptions, initialRequestFormat: wireFormat, customProviderFormat: customProviderFormat, profile: options.profile, sessionId: currentSessionId, initialMessages: options.initialMessages, runPrompt: runPrompt, listBackgroundTasks: listBackgroundTasks, checkBackgroundTask: checkBackgroundTask, onUiLine: appendLogLine, registerUiSink: registerUiSink, registerModeSink: registerModeSink, registerQuestionsSink: registerQuestionsSink, loadModels: loadModels, onModelChange: onModelChange, onModeChange: onModeChange, onWireFormatChange: onWireFormatChange, onSessionCreated: onSessionCreated, onMessagesUpdate: onMessagesUpdate, getCurrentMessages: () => activeAgent?.getMessages() ?? currentSession?.messages ?? [], onMemoryCommand: onMemoryCommand, onHooksCommand: onHooksCommand }));
1596
1940
  }
1597
1941
  finally {
1598
1942
  process.off('unhandledRejection', onUnhandledRejection);