thatgfsj-code 1.0.3 → 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 (57) hide show
  1. package/.nwt/meta.json +5 -0
  2. package/CHANGELOG.md +244 -0
  3. package/DEVELOPMENT.md +286 -0
  4. package/dist/app/index.d.ts +14 -0
  5. package/dist/app/index.d.ts.map +1 -1
  6. package/dist/app/index.js +21 -5
  7. package/dist/app/index.js.map +1 -1
  8. package/dist/cmd/index.d.ts +21 -0
  9. package/dist/cmd/index.d.ts.map +1 -1
  10. package/dist/cmd/index.js +144 -12
  11. package/dist/cmd/index.js.map +1 -1
  12. package/dist/session/index.d.ts +11 -0
  13. package/dist/session/index.d.ts.map +1 -1
  14. package/dist/session/index.js +83 -1
  15. package/dist/session/index.js.map +1 -1
  16. package/dist/tui/components/ChatList.d.ts.map +1 -1
  17. package/dist/tui/components/ChatList.js +4 -3
  18. package/dist/tui/components/ChatList.js.map +1 -1
  19. package/dist/tui/components/Header.d.ts.map +1 -1
  20. package/dist/tui/components/Header.js +2 -1
  21. package/dist/tui/components/Header.js.map +1 -1
  22. package/dist/tui/components/ToolCall.d.ts +1 -1
  23. package/dist/tui/components/ToolCall.d.ts.map +1 -1
  24. package/dist/tui/components/ToolCall.js +9 -4
  25. package/dist/tui/components/ToolCall.js.map +1 -1
  26. package/dist/tui/hooks/useChat.d.ts.map +1 -1
  27. package/dist/tui/hooks/useChat.js +47 -6
  28. package/dist/tui/hooks/useChat.js.map +1 -1
  29. package/dist/tui/hooks/useCommands.d.ts.map +1 -1
  30. package/dist/tui/hooks/useCommands.js +21 -0
  31. package/dist/tui/hooks/useCommands.js.map +1 -1
  32. package/dist/tui/welcome.d.ts.map +1 -1
  33. package/dist/tui/welcome.js +3 -2
  34. package/dist/tui/welcome.js.map +1 -1
  35. package/dist/utils/thinking.d.ts +59 -0
  36. package/dist/utils/thinking.d.ts.map +1 -0
  37. package/dist/utils/thinking.js +107 -0
  38. package/dist/utils/thinking.js.map +1 -0
  39. package/dist/version.d.ts +16 -0
  40. package/dist/version.d.ts.map +1 -0
  41. package/dist/version.js +16 -0
  42. package/dist/version.js.map +1 -0
  43. package/install.bat +63 -0
  44. package/install.ps1 +238 -0
  45. package/install.sh +113 -0
  46. package/package.json +5 -2
  47. package/src/app/index.ts +21 -5
  48. package/src/cmd/index.tsx +145 -13
  49. package/src/session/index.ts +82 -1
  50. package/src/tui/components/ChatList.tsx +15 -6
  51. package/src/tui/components/Header.tsx +2 -1
  52. package/src/tui/components/ToolCall.tsx +46 -10
  53. package/src/tui/hooks/useChat.ts +50 -6
  54. package/src/tui/hooks/useCommands.ts +22 -0
  55. package/src/tui/welcome.ts +3 -2
  56. package/src/utils/thinking.ts +122 -0
  57. package/src/version.ts +16 -0
@@ -5,12 +5,65 @@
5
5
  import type { ChatMessage } from '../types.js';
6
6
  import { ContextCompactor } from './compactor.js';
7
7
 
8
+ /**
9
+ * v2.2.4 (port from v2.1.0): patterns that, if found in an assistant
10
+ * message, indicate the message is a truncated/aborted response that
11
+ * should NOT be persisted into history. Without this filter, the next
12
+ * turn's LLM sees the marker and starts echoing it back, creating a
13
+ * self-reinforcing "[已中断]" hallucination loop.
14
+ *
15
+ * v2.2.6.1 tightening (smoke-test driven): the design here is a
16
+ * two-tier check:
17
+ * 1. STRONG markers — unambiguous pollution, always drop:
18
+ * - `[已中断]` bracketed (the model emits this exact form)
19
+ * - `[interrupted]` bracketed English equivalent
20
+ * 2. WEAK markers — only drop if the message is also short (<200
21
+ * chars). Long messages mentioning "response truncated" are
22
+ * legitimate conversation (e.g. user complaints about past
23
+ * behavior), not pollution.
24
+ *
25
+ * Earlier drafts matched bare `已中断` substrings and "response
26
+ * truncated" anywhere in the message — both were too greedy and
27
+ * dropped legitimate Chinese/English conversation.
28
+ */
29
+ const POLLUTION_STRONG: RegExp[] = [
30
+ /\[已中断\]/,
31
+ /\[interrupted\]/i,
32
+ ];
33
+
34
+ const POLLUTION_WEAK: RegExp[] = [
35
+ /^\s*[\*#>\-`]*\s*\[已中断\]/m,
36
+ /^\s*[\*#>\-`]*\s*\[interrupted\]/im,
37
+ /\bresponse (was )?(truncated|cut off|interrupted)\b/i,
38
+ /\boutput (was )?(truncated|cut off|interrupted)\b/i,
39
+ ];
40
+
41
+ function looksPolluted(content: string): boolean {
42
+ if (!content) return false;
43
+ // Common gates (apply to both tiers):
44
+ // - Length: model got cut off mid-stream → pollution is short.
45
+ // A long message with [已中断] in the middle is the model
46
+ // legitimately referencing the marker, not pollution.
47
+ // - Question: ends with '?' → user complaining about past behavior.
48
+ // - Temporal: contains "last time" / "earlier" / etc. → past tense.
49
+ if (content.length >= 200) return false;
50
+ if (/\?\s*$/.test(content.trim())) return false;
51
+ if (/\b(last time|earlier|before|previously|yesterday)\b/i.test(content)) return false;
52
+ // STRONG markers — bracketed truncation markers, unambiguous.
53
+ if (POLLUTION_STRONG.some(p => p.test(content))) return true;
54
+ // WEAK markers — bare "response truncated" phrases.
55
+ if (POLLUTION_WEAK.some(p => p.test(content))) return true;
56
+ return false;
57
+ }
58
+
8
59
  export class SessionManager {
9
60
  private messages: ChatMessage[] = [];
10
61
  private sessionId: string;
11
62
  private createdAt: Date;
12
63
  private compactor: ContextCompactor;
13
64
  private maxMessages: number;
65
+ /** v2.2.4: counter for messages dropped by the pollution filter. */
66
+ private droppedCount: number = 0;
14
67
 
15
68
  constructor(maxMessages = 50) {
16
69
  this.maxMessages = maxMessages;
@@ -24,6 +77,26 @@ export class SessionManager {
24
77
  this.autoCompact();
25
78
  }
26
79
 
80
+ /**
81
+ * v2.2.4 (port from v2.1.0): same as addMessage but returns false
82
+ * (and skips the push) if the message content matches a known
83
+ * truncation/abort pollution pattern. Use this for assistant
84
+ * messages whose stream might have been aborted.
85
+ */
86
+ addMessageSafe(role: ChatMessage['role'], content: string, extras?: Partial<ChatMessage>): boolean {
87
+ if (role === 'assistant' && looksPolluted(content)) {
88
+ this.droppedCount++;
89
+ return false;
90
+ }
91
+ this.addMessage(role, content, extras);
92
+ return true;
93
+ }
94
+
95
+ /** v2.2.4: total messages dropped by addMessageSafe since session start. */
96
+ getDroppedCount(): number {
97
+ return this.droppedCount;
98
+ }
99
+
27
100
  getMessages(): ChatMessage[] {
28
101
  return [...this.messages];
29
102
  }
@@ -62,8 +135,16 @@ export class SessionManager {
62
135
 
63
136
  truncate(maxMessages?: number): void {
64
137
  if (maxMessages) {
138
+ // v2.2.7 edge fix: preserveRecent must be <= maxMessages - 1,
139
+ // otherwise the compactor's "always keep recent N" branch
140
+ // overrides the trim and no messages actually get dropped.
141
+ // Set preserveRecent to maxMessages-1 so anything beyond the
142
+ // last maxMessages-1 user/assistant msgs gets summarized.
65
143
  this.maxMessages = maxMessages;
66
- this.compactor = new ContextCompactor({ maxMessages });
144
+ this.compactor = new ContextCompactor({
145
+ maxMessages,
146
+ preserveRecent: Math.max(1, maxMessages - 1),
147
+ });
67
148
  }
68
149
  const { compacted } = this.compactor.compact(this.messages);
69
150
  this.messages = compacted;
@@ -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.3</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
 
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Thinking block extraction + compression
3
+ *
4
+ * Models that aren't truly reasoning-capable (Qwen, DeepSeek-V3 chat,
5
+ * Kimi, etc.) still emit `<think>...</think>` blocks as plain text
6
+ * inside their response. These blocks can be hundreds of lines of
7
+ * internal monologue that the user doesn't want to see scroll by.
8
+ *
9
+ * opencode handles this by separating reasoning into its own stream
10
+ * part at the protocol level. We don't have that luxury — the model
11
+ * is emitting everything as a single `content` field. So we use the
12
+ * same regex-strip approach opencode uses internally (see
13
+ * packages/opencode/src/session/prompt.ts line 244).
14
+ *
15
+ * Three block delimiters are supported:
16
+ * 1. <think>...</think> — DeepSeek / Qwen / Kimi
17
+ * 2. <reasoning>...</reasoning> — OpenRouter-style
18
+ * 3. [THINK]...[/THINK] — some custom fine-tunes
19
+ *
20
+ * Usage:
21
+ * const { thinking, conclusion } = splitThinking(fullContent);
22
+ * if (thinking && compress) {
23
+ * render(`💭 ${summarize(thinking)}\n${conclusion}`);
24
+ * } else {
25
+ * render(fullContent);
26
+ * }
27
+ */
28
+
29
+ export interface ThinkingSplit {
30
+ /** Raw `<think>...` content, or empty if none. */
31
+ thinking: string;
32
+ /** Content with thinking blocks stripped + trimmed. */
33
+ conclusion: string;
34
+ /** How many lines the thinking block spanned. */
35
+ thinkingLines: number;
36
+ /** First non-empty line of the thinking block (heuristic summary). */
37
+ thinkingHint: string;
38
+ }
39
+
40
+ /**
41
+ * All known thinking-block delimiters, in priority order. The regex
42
+ * flags `gi` (global, case-insensitive) so `[THINK]` and `<think>`
43
+ * are both caught, and so multiple blocks collapse cleanly.
44
+ */
45
+ const THINKING_PATTERNS: RegExp[] = [
46
+ /<think>[\s\S]*?<\/think>/gi,
47
+ /<thinking>[\s\S]*?<\/thinking>/gi,
48
+ /<reasoning>[\s\S]*?<\/reasoning>/gi,
49
+ /<THINK>[\s\S]*?<\/THINK>/gi, // v2.2.7 edge: case variant
50
+ /\[THINK\][\s\S]*?\[\/THINK\]/gi,
51
+ /\[\/?think\]/gi, // bare [think] / [/think] (rare)
52
+ ];
53
+
54
+ export function splitThinking(content: string): ThinkingSplit {
55
+ if (!content) {
56
+ return { thinking: '', conclusion: '', thinkingLines: 0, thinkingHint: '' };
57
+ }
58
+
59
+ let thinking = '';
60
+ let remaining = content;
61
+
62
+ for (const pat of THINKING_PATTERNS) {
63
+ const matches = remaining.match(pat);
64
+ if (matches) {
65
+ thinking += matches.join('\n');
66
+ remaining = remaining.replace(pat, '');
67
+ }
68
+ }
69
+
70
+ const conclusion = remaining.trim();
71
+ const thinkingLines = thinking ? thinking.split('\n').length : 0;
72
+ const thinkingHint = firstNonEmptyLine(thinking);
73
+
74
+ return { thinking, conclusion, thinkingLines, thinkingHint };
75
+ }
76
+
77
+ /**
78
+ * Build a short, single-line summary of a thinking block, suitable
79
+ * for collapsed display. Returns something like:
80
+ * "💭 thought for 24 lines: The user is asking about Win+E..."
81
+ * Falls back to "(no hint)" if the block is empty.
82
+ */
83
+ export function summarizeThinking(split: ThinkingSplit): string {
84
+ if (!split.thinking) return '';
85
+ const hint = split.thinkingHint.length > 60
86
+ ? split.thinkingHint.slice(0, 57) + '...'
87
+ : split.thinkingHint;
88
+ return `💭 thought for ${split.thinkingLines} line${split.thinkingLines === 1 ? '' : 's'}${hint ? `: ${hint}` : ''}`;
89
+ }
90
+
91
+ /**
92
+ * Render full content with thinking blocks compressed (collapsed to
93
+ * a single-line indicator). If `showThinking` is true, returns the
94
+ * content unchanged (debug mode).
95
+ */
96
+ export function compressThinking(content: string, showThinking: boolean = false): string {
97
+ if (showThinking || !content) return content;
98
+ const split = splitThinking(content);
99
+ if (!split.thinking) return content;
100
+ const summary = summarizeThinking(split);
101
+ return summary ? `${summary}\n${split.conclusion}` : split.conclusion;
102
+ }
103
+
104
+ function firstNonEmptyLine(s: string): string {
105
+ if (!s) return '';
106
+ for (const line of s.split('\n')) {
107
+ // Strip any leading/trailing thinking-tag fragments that may have
108
+ // been captured by the regex (e.g. "<think>The user said..."
109
+ // or "...</think>"). We strip ALL occurrences to handle the
110
+ // case where the first line wraps across a tag.
111
+ const t = line
112
+ .trim()
113
+ .replace(/<\/?think>/gi, '')
114
+ .replace(/<\/?reasoning>/gi, '')
115
+ .replace(/\[\/?THINK\]/gi, '')
116
+ .trim();
117
+ if (t) return t;
118
+ }
119
+ return '';
120
+ }
121
+
122
+ export { THINKING_PATTERNS };
package/src/version.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Product version (what users see in --version, the TUI header, and
3
+ * the welcome screen). This is SEPARATE from the npm package version
4
+ * (`package.json` `version` field) — see the dual-version scheme docs
5
+ * in CHANGELOG.md (v2.2.2 entry).
6
+ *
7
+ * Bump this when shipping a user-visible change. Bump package.json
8
+ * version (npm version) at the same time. They are both +0.0.1 per
9
+ * release.
10
+ */
11
+ export const PRODUCT_VERSION = '0.4.6';
12
+
13
+ /**
14
+ * Convenience: format with leading "v" for display contexts.
15
+ */
16
+ export const PRODUCT_VERSION_DISPLAY = `v${PRODUCT_VERSION}`;