thatgfsj-code 1.0.2 → 2.2.9

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.
Files changed (63) hide show
  1. package/.nwt/meta.json +5 -0
  2. package/CHANGELOG.md +244 -0
  3. package/DEVELOPMENT.md +286 -0
  4. package/README.md +23 -1
  5. package/dist/app/index.d.ts +14 -0
  6. package/dist/app/index.d.ts.map +1 -1
  7. package/dist/app/index.js +21 -5
  8. package/dist/app/index.js.map +1 -1
  9. package/dist/cmd/index.d.ts +21 -0
  10. package/dist/cmd/index.d.ts.map +1 -1
  11. package/dist/cmd/index.js +144 -12
  12. package/dist/cmd/index.js.map +1 -1
  13. package/dist/session/index.d.ts +11 -0
  14. package/dist/session/index.d.ts.map +1 -1
  15. package/dist/session/index.js +83 -1
  16. package/dist/session/index.js.map +1 -1
  17. package/dist/tools/nwt.d.ts +14 -4
  18. package/dist/tools/nwt.d.ts.map +1 -1
  19. package/dist/tools/nwt.js +160 -13
  20. package/dist/tools/nwt.js.map +1 -1
  21. package/dist/tui/components/ChatList.d.ts.map +1 -1
  22. package/dist/tui/components/ChatList.js +4 -3
  23. package/dist/tui/components/ChatList.js.map +1 -1
  24. package/dist/tui/components/Header.d.ts.map +1 -1
  25. package/dist/tui/components/Header.js +2 -1
  26. package/dist/tui/components/Header.js.map +1 -1
  27. package/dist/tui/components/ToolCall.d.ts +1 -1
  28. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  29. package/dist/tui/components/ToolCall.js +9 -4
  30. package/dist/tui/components/ToolCall.js.map +1 -1
  31. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  32. package/dist/tui/hooks/useChat.js +47 -6
  33. package/dist/tui/hooks/useChat.js.map +1 -1
  34. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  35. package/dist/tui/hooks/useCommands.js +21 -0
  36. package/dist/tui/hooks/useCommands.js.map +1 -1
  37. package/dist/tui/welcome.d.ts.map +1 -1
  38. package/dist/tui/welcome.js +3 -2
  39. package/dist/tui/welcome.js.map +1 -1
  40. package/dist/utils/thinking.d.ts +59 -0
  41. package/dist/utils/thinking.d.ts.map +1 -0
  42. package/dist/utils/thinking.js +107 -0
  43. package/dist/utils/thinking.js.map +1 -0
  44. package/dist/version.d.ts +16 -0
  45. package/dist/version.d.ts.map +1 -0
  46. package/dist/version.js +16 -0
  47. package/dist/version.js.map +1 -0
  48. package/install.bat +63 -0
  49. package/install.ps1 +238 -0
  50. package/install.sh +113 -0
  51. package/package.json +5 -2
  52. package/src/app/index.ts +21 -5
  53. package/src/cmd/index.tsx +145 -13
  54. package/src/session/index.ts +82 -1
  55. package/src/tools/nwt.ts +181 -13
  56. package/src/tui/components/ChatList.tsx +15 -6
  57. package/src/tui/components/Header.tsx +2 -1
  58. package/src/tui/components/ToolCall.tsx +46 -10
  59. package/src/tui/hooks/useChat.ts +50 -6
  60. package/src/tui/hooks/useCommands.ts +22 -0
  61. package/src/tui/welcome.ts +3 -2
  62. package/src/utils/thinking.ts +122 -0
  63. package/src/version.ts +16 -0
package/src/tools/nwt.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { Tool, ToolResult } from './types.js';
10
10
  import {
11
11
  readFileSync, writeFileSync, existsSync, mkdirSync,
12
- readdirSync, renameSync
12
+ readdirSync, renameSync, unlinkSync
13
13
  } from 'fs';
14
14
  import { join } from 'path';
15
15
 
@@ -22,6 +22,7 @@ interface TimelineEvent {
22
22
  files: string[];
23
23
  tags: string[];
24
24
  parent?: string;
25
+ importance: 'low' | 'normal' | 'high' | 'milestone';
25
26
  }
26
27
 
27
28
  const NWT_DIR = '.nwt';
@@ -32,22 +33,41 @@ const ARCHIVE_AFTER_DAYS = 30;
32
33
 
33
34
  export class NwtTool implements Tool {
34
35
  name = 'nwt';
35
- description = 'NeuroWeave Timeline - Project evolution memory. Log decisions, track changes, search history. Actions: init, log, history, search, story, explain, archive';
36
+ description = `NeuroWeave Timeline - Project evolution memory.
37
+ Actions: init, log, history, search, story, explain, archive, diff, compact
38
+
39
+ AUTO-LOG TRIGGERS (call nwt log silently when):
40
+ - Created, modified, or deleted 2+ files
41
+ - Made an architectural decision (chose a library, pattern, or approach)
42
+ - Fixed a bug
43
+ - Added a new feature or capability
44
+ - Refactored existing code
45
+ - Set up a new project or module
46
+ - Resolved a security issue
47
+ - Completed a multi-step task
48
+
49
+ DO NOT log:
50
+ - Simple file reads or searches
51
+ - Trivial single-line changes
52
+ - Conversational questions with no code changes`;
36
53
 
37
54
  inputSchema = {
38
55
  type: 'object' as const,
39
56
  properties: {
40
57
  action: {
41
58
  type: 'string',
42
- description: 'Action: init, log, history, search, story, explain, archive',
59
+ description: 'Action: init, log, history, search, story, explain, archive, diff, compact',
43
60
  },
44
61
  task: { type: 'string', description: 'Short imperative title (for log)' },
45
62
  summary: { type: 'string', description: 'What was done (for log)' },
46
63
  reason: { type: 'string', description: 'Why it was done (for log)' },
47
64
  files: { type: 'string', description: 'Comma-separated file paths (for log)' },
48
65
  tags: { type: 'string', description: 'Comma-separated tags (for log)' },
66
+ importance: { type: 'string', description: 'Importance: low, normal, high, milestone (for log)' },
49
67
  query: { type: 'string', description: 'Search query (for search/explain)' },
50
68
  limit: { type: 'number', description: 'Max results (for history/search)' },
69
+ from_id: { type: 'string', description: 'Start event ID (for diff)' },
70
+ to_id: { type: 'string', description: 'End event ID (for diff)' },
51
71
  },
52
72
  required: ['action'],
53
73
  };
@@ -64,8 +84,11 @@ export class NwtTool implements Tool {
64
84
  { name: 'reason', type: 'string', description: 'Why it was done', required: false },
65
85
  { name: 'files', type: 'string', description: 'Comma-separated file paths', required: false },
66
86
  { name: 'tags', type: 'string', description: 'Comma-separated tags', required: false },
87
+ { name: 'importance', type: 'string', description: 'low/normal/high/milestone', required: false },
67
88
  { name: 'query', type: 'string', description: 'Search query', required: false },
68
89
  { name: 'limit', type: 'number', description: 'Max results', required: false },
90
+ { name: 'from_id', type: 'string', description: 'Start event ID for diff', required: false },
91
+ { name: 'to_id', type: 'string', description: 'End event ID for diff', required: false },
69
92
  ];
70
93
 
71
94
  async execute(params: Record<string, any>, ctx?: any): Promise<ToolResult> {
@@ -88,6 +111,10 @@ export class NwtTool implements Tool {
88
111
  return this.explain(cwd, params.query || '');
89
112
  case 'archive':
90
113
  return this.archive(cwd);
114
+ case 'diff':
115
+ return this.diff(cwd, params.from_id || '', params.to_id || '');
116
+ case 'compact':
117
+ return this.compact(cwd);
91
118
  default:
92
119
  return { success: false, error: `Unknown action: ${action}` };
93
120
  }
@@ -112,7 +139,7 @@ export class NwtTool implements Tool {
112
139
 
113
140
  // Write metadata
114
141
  const meta = {
115
- version: '1.0.0',
142
+ version: '2.0.0',
116
143
  created: new Date().toISOString(),
117
144
  description: 'NeuroWeave Timeline - Project evolution memory',
118
145
  };
@@ -129,7 +156,7 @@ export class NwtTool implements Tool {
129
156
  this.init(cwd);
130
157
  }
131
158
 
132
- const { task, summary, reason, files, tags } = params;
159
+ const { task, summary, reason, files, tags, importance } = params;
133
160
  if (!task || !summary) {
134
161
  return { success: false, error: 'task and summary are required' };
135
162
  }
@@ -146,6 +173,10 @@ export class NwtTool implements Tool {
146
173
  ? existing.sort().pop()?.replace('.json', '')
147
174
  : undefined;
148
175
 
176
+ // Validate importance
177
+ const validImportance = ['low', 'normal', 'high', 'milestone'];
178
+ const eventImportance = validImportance.includes(importance) ? importance : 'normal';
179
+
149
180
  const event: TimelineEvent = {
150
181
  id: nextId,
151
182
  timestamp: new Date().toISOString(),
@@ -155,6 +186,7 @@ export class NwtTool implements Tool {
155
186
  files: files ? files.split(',').map((f: string) => f.trim()).filter(Boolean) : [],
156
187
  tags: tags ? tags.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) : [],
157
188
  parent: parent || undefined,
189
+ importance: eventImportance as TimelineEvent['importance'],
158
190
  };
159
191
 
160
192
  writeFileSync(
@@ -186,7 +218,8 @@ export class NwtTool implements Tool {
186
218
  const time = e.timestamp.split('T')[0];
187
219
  const files = e.files.length > 0 ? ` [${e.files.join(', ')}]` : '';
188
220
  const tags = e.tags.length > 0 ? ` {${e.tags.join(', ')}}` : '';
189
- return `[${e.id}] ${time} ${e.task}${files}${tags}`;
221
+ const imp = e.importance && e.importance !== 'normal' ? ` (${e.importance})` : '';
222
+ return `[${e.id}] ${time} ${e.task}${imp}${files}${tags}`;
190
223
  });
191
224
 
192
225
  return { success: true, output: lines.join('\n') };
@@ -227,8 +260,9 @@ export class NwtTool implements Tool {
227
260
  return { success: true, output: 'No events yet.' };
228
261
  }
229
262
 
230
- // Group by tags and find milestones
231
- const milestones = events.filter(e => e.tags.includes('milestone'));
263
+ // Group by importance
264
+ const milestones = events.filter(e => e.importance === 'milestone' || e.tags.includes('milestone'));
265
+ const highImportance = events.filter(e => e.importance === 'high');
232
266
  const decisions = events.filter(e => e.tags.includes('decision'));
233
267
  const totalFiles = new Set(events.flatMap(e => e.files)).size;
234
268
 
@@ -245,6 +279,14 @@ export class NwtTool implements Tool {
245
279
  lines.push('');
246
280
  }
247
281
 
282
+ if (highImportance.length > 0) {
283
+ lines.push('High Importance:');
284
+ for (const h of highImportance.slice(-5)) {
285
+ lines.push(` [${h.id}] ${h.task} - ${h.summary}`);
286
+ }
287
+ lines.push('');
288
+ }
289
+
248
290
  if (decisions.length > 0) {
249
291
  lines.push('Key Decisions:');
250
292
  for (const d of decisions.slice(-5)) {
@@ -256,7 +298,8 @@ export class NwtTool implements Tool {
256
298
  lines.push('Recent Activity:');
257
299
  for (const e of events.slice(-5)) {
258
300
  const time = e.timestamp.split('T')[0];
259
- lines.push(` ${time} ${e.task}`);
301
+ const imp = e.importance && e.importance !== 'normal' ? ` (${e.importance})` : '';
302
+ lines.push(` ${time} ${e.task}${imp}`);
260
303
  }
261
304
 
262
305
  return { success: true, output: lines.join('\n') };
@@ -284,6 +327,135 @@ export class NwtTool implements Tool {
284
327
  return { success: true, output: `History of ${filePath}:\n\n${lines.join('\n\n')}` };
285
328
  }
286
329
 
330
+ // ── Diff ──────────────────────────────────────────────
331
+
332
+ private diff(cwd: string, fromId: string, toId: string): ToolResult {
333
+ if (!fromId || !toId) {
334
+ return { success: false, error: 'from_id and to_id are required' };
335
+ }
336
+
337
+ const events = this.loadEvents(cwd);
338
+ const fromEvent = events.find(e => e.id === fromId.padStart(6, '0'));
339
+ const toEvent = events.find(e => e.id === toId.padStart(6, '0'));
340
+
341
+ if (!fromEvent) return { success: false, error: `Event ${fromId} not found` };
342
+ if (!toEvent) return { success: false, error: `Event ${toId} not found` };
343
+
344
+ // Get events between from and to
345
+ const fromIdx = events.indexOf(fromEvent);
346
+ const toIdx = events.indexOf(toEvent);
347
+ const between = events.slice(fromIdx, toIdx + 1);
348
+
349
+ // Collect all files touched
350
+ const allFiles = new Set(between.flatMap(e => e.files));
351
+ const fromFiles = new Set(fromEvent.files);
352
+ const toFiles = new Set(toEvent.files);
353
+
354
+ // Files added/removed/modified
355
+ const added = [...toFiles].filter(f => !fromFiles.has(f));
356
+ const removed = [...fromFiles].filter(f => !toFiles.has(f));
357
+ const modified = [...allFiles].filter(f => fromFiles.has(f) && toFiles.has(f));
358
+
359
+ const lines = [
360
+ `Diff: [${fromEvent.id}] → [${toEvent.id}]`,
361
+ `Events: ${between.length} between these points`,
362
+ '',
363
+ ];
364
+
365
+ if (added.length > 0) lines.push(`Added: ${added.join(', ')}`);
366
+ if (removed.length > 0) lines.push(`Removed: ${removed.join(', ')}`);
367
+ if (modified.length > 0) lines.push(`Modified: ${modified.join(', ')}`);
368
+
369
+ lines.push('', 'Events in range:');
370
+ for (const e of between) {
371
+ const time = e.timestamp.split('T')[0];
372
+ lines.push(` [${e.id}] ${time} ${e.task}`);
373
+ }
374
+
375
+ return { success: true, output: lines.join('\n') };
376
+ }
377
+
378
+ // ── Compact ───────────────────────────────────────────
379
+
380
+ private compact(cwd: string): ToolResult {
381
+ const events = this.loadEvents(cwd);
382
+ if (events.length < 10) {
383
+ return { success: true, output: 'Not enough events to compact (need 10+).' };
384
+ }
385
+
386
+ const nwtDir = join(cwd, NWT_DIR);
387
+ const eventsDir = join(nwtDir, EVENTS_DIR);
388
+
389
+ // Group consecutive events with same tags
390
+ const groups: TimelineEvent[][] = [];
391
+ let currentGroup: TimelineEvent[] = [events[0]];
392
+
393
+ for (let i = 1; i < events.length; i++) {
394
+ const prev = events[i - 1];
395
+ const curr = events[i];
396
+
397
+ // Same tags and close in time (within 1 hour)
398
+ const sameTags = curr.tags.join(',') === prev.tags.join(',');
399
+ const timeDiff = new Date(curr.timestamp).getTime() - new Date(prev.timestamp).getTime();
400
+ const closeInTime = timeDiff < 3600000; // 1 hour
401
+
402
+ if (sameTags && closeInTime) {
403
+ currentGroup.push(curr);
404
+ } else {
405
+ groups.push(currentGroup);
406
+ currentGroup = [curr];
407
+ }
408
+ }
409
+ groups.push(currentGroup);
410
+
411
+ // Merge groups with 3+ events
412
+ let merged = 0;
413
+ const newEvents: TimelineEvent[] = [];
414
+
415
+ for (const group of groups) {
416
+ if (group.length >= 3) {
417
+ // Merge into one summary event
418
+ const first = group[0];
419
+ const last = group[group.length - 1];
420
+ const allFiles = [...new Set(group.flatMap(e => e.files))];
421
+ const allTags = [...new Set(group.flatMap(e => e.tags))];
422
+
423
+ newEvents.push({
424
+ id: first.id,
425
+ timestamp: first.timestamp,
426
+ task: `${first.task} ... ${last.task}`,
427
+ summary: `Compacted ${group.length} events: ${group.map(e => e.task).join(', ')}`,
428
+ files: allFiles,
429
+ tags: allTags,
430
+ importance: first.importance,
431
+ });
432
+ merged += group.length - 1;
433
+ } else {
434
+ newEvents.push(...group);
435
+ }
436
+ }
437
+
438
+ if (merged === 0) {
439
+ return { success: true, output: 'No events to compact.' };
440
+ }
441
+
442
+ // Re-write events
443
+ for (const file of readdirSync(eventsDir).filter(f => f.endsWith('.json'))) {
444
+ unlinkSync(join(eventsDir, file));
445
+ }
446
+
447
+ for (let i = 0; i < newEvents.length; i++) {
448
+ const event = newEvents[i];
449
+ event.id = (i + 1).toString().padStart(6, '0');
450
+ writeFileSync(join(eventsDir, `${event.id}.json`), JSON.stringify(event, null, 2));
451
+ }
452
+
453
+ return {
454
+ success: true,
455
+ output: `Compacted: ${events.length} → ${newEvents.length} events (merged ${merged})`,
456
+ };
457
+ }
458
+
287
459
  // ── Archive ───────────────────────────────────────────
288
460
 
289
461
  private archive(cwd: string): ToolResult {
@@ -345,10 +517,6 @@ export class NwtTool implements Tool {
345
517
 
346
518
  // ── Auto Archive ──────────────────────────────────────
347
519
 
348
- /**
349
- * Auto-archive events older than 30 days.
350
- * Runs silently on init - no output unless something was archived.
351
- */
352
520
  private autoArchiveIfNeeded(cwd: string): void {
353
521
  try {
354
522
  const nwtDir = join(cwd, NWT_DIR);
@@ -1,6 +1,6 @@
1
1
  /** @jsxImportSource react */
2
2
  import React, { memo } from 'react';
3
- import { Box } from 'ink';
3
+ import { Box, Static } from 'ink';
4
4
  import { ChatMessage, type MessageData } from './ChatMessage.js';
5
5
  import type { ToolCallData } from './ToolCall.js';
6
6
 
@@ -12,17 +12,26 @@ interface Props {
12
12
  }
13
13
 
14
14
  export const ChatList = memo(function ChatList({ messages, streaming, streamingToolCalls, width }: Props) {
15
+ const hasStreaming = !!(streaming || (streamingToolCalls && streamingToolCalls.length > 0));
16
+
15
17
  return (
16
18
  <Box flexDirection="column">
17
- {messages.map((msg, i) => (
18
- <ChatMessage key={i} message={msg} width={width} />
19
- ))}
20
- {(streaming || streamingToolCalls) && (
19
+ {/* Completed messages - Static prevents re-rendering */}
20
+ {messages.length > 0 && (
21
+ <Static items={messages}>
22
+ {(msg: MessageData, index: number) => (
23
+ <ChatMessage key={`msg-${index}`} message={msg} width={width} />
24
+ )}
25
+ </Static>
26
+ )}
27
+
28
+ {/* Streaming content - only this part re-renders */}
29
+ {hasStreaming && (
21
30
  <ChatMessage
22
31
  message={{
23
32
  role: 'assistant',
24
33
  content: streaming || '',
25
- toolCalls: streamingToolCalls,
34
+ toolCalls: streamingToolCalls && streamingToolCalls.length > 0 ? streamingToolCalls : undefined,
26
35
  }}
27
36
  width={width}
28
37
  />
@@ -1,6 +1,7 @@
1
1
  /** @jsxImportSource react */
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
+ import { PRODUCT_VERSION_DISPLAY } from '../../version.js';
4
5
 
5
6
  interface Props {
6
7
  provider: string;
@@ -14,7 +15,7 @@ export const Header = React.memo(function Header({ provider, model }: Props) {
14
15
  <Box>
15
16
  <Text color="#06B6D4" bold> ⚡ </Text>
16
17
  <Text color="#22D3EE" bold>THATGFSJ CODE</Text>
17
- <Text dimColor> v1.0.2</Text>
18
+ <Text dimColor> {PRODUCT_VERSION_DISPLAY}</Text>
18
19
  </Box>
19
20
  <Box>
20
21
  <Text color="#06B6D4" bold> {provider} </Text>
@@ -40,9 +40,14 @@ function truncateOutput(output: string, maxLines = 8): { text: string; truncated
40
40
  return { text: lines.slice(0, maxLines).join('\n'), truncated: true };
41
41
  }
42
42
 
43
- export function ToolCall({ tool }: Props) {
43
+ export function ToolCall({ tool, width }: Props) {
44
44
  const label = formatToolName(tool.name, tool.args);
45
- const result = tool.result ? truncateOutput(tool.result) : null;
45
+ // v2.2.7 edge: treat null same as undefined (both mean "still
46
+ // running"). Previous check `result !== undefined` would render
47
+ // null as an empty string output, causing confusion.
48
+ const result = (tool.result !== undefined && tool.result !== null)
49
+ ? truncateOutput(tool.result)
50
+ : null;
46
51
 
47
52
  return (
48
53
  <Box flexDirection="column" marginBottom={0} paddingLeft={1}>
@@ -53,15 +58,46 @@ export function ToolCall({ tool }: Props) {
53
58
  {label && <Text color="#64748B"> {label}</Text>}
54
59
  </Box>
55
60
 
56
- {/* Result */}
57
- {result && (
61
+ {/* Result — v2.2.6 fix:
62
+ - Use `!== undefined` (not truthy) so empty string results
63
+ still render (truthy check dropped them, making it look
64
+ like the tool result was missing).
65
+ - Switch wrap from "truncate" to "wrap" so long lines don't
66
+ silently disappear off-screen.
67
+ - Always render the Box even when result is empty (just
68
+ with a "(no output)" marker), so the visual frame stays
69
+ consistent.
70
+ - If truncated, show explicit "(+N more lines)" indicator
71
+ so users know the result was clipped. */}
72
+ {result !== null && (
58
73
  <Box paddingLeft={2} flexDirection="column">
59
- {result.text.split('\n').map((line, i) => (
60
- <Text key={i} color={tool.isError ? '#EF4444' : '#64748B'} wrap="truncate">
61
- {line}
62
- </Text>
63
- ))}
64
- {result.truncated && <Text dimColor> ...</Text>}
74
+ {result.text.length === 0 ? (
75
+ <Text color="#94A3B8" dimColor>(no output)</Text>
76
+ ) : (
77
+ <>
78
+ {result.text.split('\n').map((line, i) => (
79
+ <Text
80
+ key={i}
81
+ color={tool.isError ? '#EF4444' : '#64748B'}
82
+ wrap="wrap"
83
+ >
84
+ {line || ' '}
85
+ </Text>
86
+ ))}
87
+ {result.truncated && (
88
+ <Text color="#94A3B8" dimColor>
89
+ {' '}(+{tool.result!.split('\n').length - 8} more lines)
90
+ </Text>
91
+ )}
92
+ </>
93
+ )}
94
+ </Box>
95
+ )}
96
+ {/* v2.2.6: if result is undefined (still running), show a
97
+ pending indicator so the user knows the tool is in flight. */}
98
+ {result === null && tool.result === undefined && (
99
+ <Box paddingLeft={2}>
100
+ <Text color="#94A3B8" dimColor> ⏳ running...</Text>
65
101
  </Box>
66
102
  )}
67
103
  </Box>
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef } from 'react';
2
2
  import type { MessageData } from '../components/ChatMessage.js';
3
3
  import type { ToolCallData } from '../components/ToolCall.js';
4
4
  import type { App } from '../../app/index.js';
5
+ import { compressThinking, splitThinking, summarizeThinking } from '../../utils/thinking.js';
5
6
 
6
7
  interface ChatState {
7
8
  messages: MessageData[];
@@ -95,20 +96,63 @@ export function useChat(app: App) {
95
96
  }
96
97
  }
97
98
 
98
- // Save response (even if aborted, save what we have)
99
- if (fullContent.trim() || currentToolCalls.length > 0) {
100
- const suffix = abortRef.current ? '\n\n[已中断]' : '';
101
- app.session.addMessage('assistant', fullContent + suffix);
99
+ // v2.2.4 (port from v2.1.0): DO NOT persist truncated assistant
100
+ // messages. The previous code literally wrote `'\n\n[已中断]'`
101
+ // as a suffix and persisted it which is what created the
102
+ // hallucination loop where the next turn's LLM echoed the
103
+ // marker back. The fix is two-pronged:
104
+ // 1. Never persist when the stream was aborted (here).
105
+ // 2. SessionManager.addMessageSafe drops messages that match
106
+ // the pollution filter as a belt-and-suspenders check
107
+ // for cases where we somehow persist a polluted message.
108
+ const wasAborted = abortRef.current;
109
+ const shouldPersist = !wasAborted &&
110
+ (fullContent.trim() || currentToolCalls.length > 0);
111
+
112
+ if (shouldPersist) {
113
+ // v2.2.5: strip <think> blocks from the persisted message
114
+ // when compression is enabled. Same rationale as in
115
+ // cmd/index.tsx — keeps history compact, avoids re-feeding
116
+ // reasoning into the next turn's context window.
117
+ const toPersist = compressThinking(fullContent, app.showThinking);
118
+ app.session.addMessageSafe('assistant', toPersist);
102
119
  }
103
120
 
121
+ // v2.2.5: build a displayable version. When thinking is hidden
122
+ // we still want the user to see a one-line indicator of how
123
+ // much reasoning the model did, plus the conclusion.
124
+ const split = splitThinking(fullContent);
125
+ const displayContent = app.showThinking
126
+ ? fullContent
127
+ : (split.thinking
128
+ ? `${summarizeThinking(split)}\n${split.conclusion}`
129
+ : fullContent);
130
+
131
+ // v2.2.6 (tool-result belt-and-suspenders): if any tool call
132
+ // returned text, also append a compact "[tool: name → result]"
133
+ // summary to the persisted/displayed content. This guarantees
134
+ // the user sees the tool output regardless of whether the Ink
135
+ // <ToolCall/> component renders it correctly. Past sessions
136
+ // have had cases where the streaming ToolCall rendering failed
137
+ // silently (e.g. result was empty string, wrap=truncate cut
138
+ // long output off-screen) and the user had no idea what the
139
+ // tool actually returned.
140
+ const toolSummary = currentToolCalls.length > 0
141
+ ? '\n\n' + currentToolCalls.map((tc) => {
142
+ const r = tc.result !== undefined ? tc.result : '(no result)';
143
+ const short = r.length > 200 ? r.slice(0, 197) + '...' : r;
144
+ return `[tool: ${tc.name} → ${short}]`;
145
+ }).join('\n')
146
+ : '';
147
+
104
148
  setState(prev => ({
105
149
  ...prev,
106
150
  messages: [
107
151
  ...prev.messages,
108
- ...(fullContent.trim() || currentToolCalls.length > 0
152
+ ...(shouldPersist
109
153
  ? [{
110
154
  role: 'assistant' as const,
111
- content: fullContent + (abortRef.current ? '\n\n[已中断]' : ''),
155
+ content: displayContent + toolSummary,
112
156
  toolCalls: currentToolCalls.length > 0 ? currentToolCalls : undefined,
113
157
  }]
114
158
  : []),
@@ -18,6 +18,7 @@ const CMD_ALIASES: Record<string, string> = {
18
18
  '/mcp': '/mcp',
19
19
  '/帮助': '/help',
20
20
  '/服务商': '/provider',
21
+ '/思考': '/thinking',
21
22
  };
22
23
 
23
24
  export const COMMAND_LIST = [
@@ -125,6 +126,26 @@ export function useCommands(app: App) {
125
126
  };
126
127
  }
127
128
 
129
+ // ── /thinking on|off ─────────────────────────────────
130
+ if (name === '/thinking' || name === '/思考') {
131
+ if (!arg) {
132
+ return {
133
+ handled: true,
134
+ output: `思考块显示: ${app.showThinking ? '开启 (显示完整 <think>...</think>)' : '关闭 (压缩为单行提示)'}`,
135
+ };
136
+ }
137
+ const want = arg.toLowerCase();
138
+ if (want === 'on' || want === '开启' || want === 'true' || want === '1') {
139
+ app.showThinking = true;
140
+ return { handled: true, output: '✓ 已开启完整思考块显示' };
141
+ }
142
+ if (want === 'off' || want === '关闭' || want === 'false' || want === '0') {
143
+ app.showThinking = false;
144
+ return { handled: true, output: '✓ 已关闭思考块显示 (压缩为单行提示)' };
145
+ }
146
+ return { handled: true, output: `用法: /thinking on|off (当前: ${app.showThinking ? 'on' : 'off'})` };
147
+ }
148
+
128
149
  // ── /help ───────────────────────────────────────────
129
150
  if (name === '/help') {
130
151
  return {
@@ -135,6 +156,7 @@ export function useCommands(app: App) {
135
156
  ' /服务商 更换服务商',
136
157
  ' /新建 新建会话',
137
158
  ' /压缩 压缩上下文',
159
+ ' /思考 [on|off] 切换思考块显示',
138
160
  ' /技能 [id] 管理技能',
139
161
  ' /mcp MCP 设置',
140
162
  ' /帮助 查看帮助',
@@ -8,6 +8,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { homedir } from 'os';
10
10
  import { PROVIDERS, getModelsForProvider, listProviders, isCustomProvider } from '../config/providers.js';
11
+ import { PRODUCT_VERSION_DISPLAY } from '../version.js';
11
12
  import type { ProviderName } from '../config/types.js';
12
13
 
13
14
  const line = chalk.gray('─'.repeat(52));
@@ -18,7 +19,7 @@ export class WelcomeScreen {
18
19
  if (hasApiKey) return;
19
20
 
20
21
  console.log();
21
- console.log(chalk.cyan.bold(' ⚡ Thatgfsj Code') + chalk.gray(' v0.5.0'));
22
+ console.log(chalk.cyan.bold(' ⚡ Thatgfsj Code') + chalk.gray(' ' + PRODUCT_VERSION_DISPLAY));
22
23
  console.log(chalk.gray(' AI Coding Assistant'));
23
24
  console.log(line);
24
25
  console.log();
@@ -35,7 +36,7 @@ export class WelcomeScreen {
35
36
  static async interactiveSetup(): Promise<void> {
36
37
  console.clear();
37
38
  console.log();
38
- console.log(chalk.cyan.bold(' ⚡ Thatgfsj Code') + chalk.gray(' - Setup'));
39
+ console.log(chalk.cyan.bold(' ⚡ Thatgfsj Code') + chalk.gray(' ' + PRODUCT_VERSION_DISPLAY + ' - Setup'));
39
40
  console.log(line);
40
41
  console.log();
41
42