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.
- package/.nwt/meta.json +5 -0
- package/CHANGELOG.md +244 -0
- package/DEVELOPMENT.md +286 -0
- package/dist/app/index.d.ts +14 -0
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +21 -5
- package/dist/app/index.js.map +1 -1
- package/dist/cmd/index.d.ts +21 -0
- package/dist/cmd/index.d.ts.map +1 -1
- package/dist/cmd/index.js +144 -12
- package/dist/cmd/index.js.map +1 -1
- package/dist/session/index.d.ts +11 -0
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +83 -1
- package/dist/session/index.js.map +1 -1
- package/dist/tui/components/ChatList.d.ts.map +1 -1
- package/dist/tui/components/ChatList.js +4 -3
- package/dist/tui/components/ChatList.js.map +1 -1
- package/dist/tui/components/Header.d.ts.map +1 -1
- package/dist/tui/components/Header.js +2 -1
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/ToolCall.d.ts +1 -1
- package/dist/tui/components/ToolCall.d.ts.map +1 -1
- package/dist/tui/components/ToolCall.js +9 -4
- package/dist/tui/components/ToolCall.js.map +1 -1
- package/dist/tui/hooks/useChat.d.ts.map +1 -1
- package/dist/tui/hooks/useChat.js +47 -6
- package/dist/tui/hooks/useChat.js.map +1 -1
- package/dist/tui/hooks/useCommands.d.ts.map +1 -1
- package/dist/tui/hooks/useCommands.js +21 -0
- package/dist/tui/hooks/useCommands.js.map +1 -1
- package/dist/tui/welcome.d.ts.map +1 -1
- package/dist/tui/welcome.js +3 -2
- package/dist/tui/welcome.js.map +1 -1
- package/dist/utils/thinking.d.ts +59 -0
- package/dist/utils/thinking.d.ts.map +1 -0
- package/dist/utils/thinking.js +107 -0
- package/dist/utils/thinking.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +16 -0
- package/dist/version.js.map +1 -0
- package/install.bat +63 -0
- package/install.ps1 +238 -0
- package/install.sh +113 -0
- package/package.json +5 -2
- package/src/app/index.ts +21 -5
- package/src/cmd/index.tsx +145 -13
- package/src/session/index.ts +82 -1
- package/src/tui/components/ChatList.tsx +15 -6
- package/src/tui/components/Header.tsx +2 -1
- package/src/tui/components/ToolCall.tsx +46 -10
- package/src/tui/hooks/useChat.ts +50 -6
- package/src/tui/hooks/useCommands.ts +22 -0
- package/src/tui/welcome.ts +3 -2
- package/src/utils/thinking.ts +122 -0
- package/src/version.ts +16 -0
package/src/session/index.ts
CHANGED
|
@@ -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({
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
60
|
-
<Text
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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>
|
package/src/tui/hooks/useChat.ts
CHANGED
|
@@ -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
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
...(
|
|
152
|
+
...(shouldPersist
|
|
109
153
|
? [{
|
|
110
154
|
role: 'assistant' as const,
|
|
111
|
-
content:
|
|
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
|
' /帮助 查看帮助',
|
package/src/tui/welcome.ts
CHANGED
|
@@ -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('
|
|
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}`;
|