Skip to content

Commit 9e919b3

Browse files
committed
✨ feat: askuser-inline
1 parent 850db4d commit 9e919b3

8 files changed

Lines changed: 151 additions & 26 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,6 @@ Desktop.ini
4949
promo
5050

5151
# 内部笔记 / 不对外的 roadmap 等
52-
.notes
52+
.notes
53+
# playwright
54+
.playwright-mcp

tui/askuser_modal.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package tui
33
import (
44
"encoding/json"
55
"fmt"
6+
"strings"
67
"time"
78

89
"charm.land/lipgloss/v2"
@@ -51,6 +52,31 @@ func (m model) buildAskAnswer() string {
5152
return string(b)
5253
}
5354

55+
// askRecord 把作答结果折叠成一段紧凑的 markdown 档案,作为 kindSystem 段写进对话流留痕(issue #134):
56+
// 每题一行「❓ 问题 → **已选答案**」(多选用顿号连接);skipped=true 时记「(已跳过)」。
57+
// 待答时的完整交互卡片(askUserBlock)消失后,scrollback 里仍能看到问了什么、选了什么。
58+
func (m model) askRecord(skipped bool) string {
59+
lines := make([]string, 0, len(m.askQuestions))
60+
for qi, q := range m.askQuestions {
61+
if skipped {
62+
lines = append(lines, fmt.Sprintf("❓ %s —(已跳过)", q.Question))
63+
continue
64+
}
65+
var sel []string
66+
for oi, on := range m.askSelected[qi] {
67+
if on {
68+
sel = append(sel, q.Options[oi].Label)
69+
}
70+
}
71+
ans := "(未选)"
72+
if len(sel) > 0 {
73+
ans = strings.Join(sel, "、")
74+
}
75+
lines = append(lines, fmt.Sprintf("❓ %s → **%s**", q.Question, ans))
76+
}
77+
return strings.Join(lines, "\n\n")
78+
}
79+
5480
// askUserBlock 渲染 AskUser 选择题弹窗(当前题)。
5581
func (m model) askUserBlock() string {
5682
if len(m.askQuestions) == 0 || m.askQIdx >= len(m.askQuestions) {

tui/askuser_record_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package tui
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"deepx/agent"
8+
)
9+
10+
// askRecord 把作答折叠成对话流档案段(issue #134)。覆盖单选/多选/未选/跳过四种形态。
11+
func TestAskRecord(t *testing.T) {
12+
m := model{
13+
askQuestions: []agent.AskQuestion{
14+
{Question: "用哪种认证方式?", Options: []agent.AskOption{{Label: "OAuth 2.0"}, {Label: "API Key"}}},
15+
{Question: "启用哪些特性?", Multiple: true, Options: []agent.AskOption{{Label: "缓存"}, {Label: "压缩"}, {Label: "重试"}}},
16+
},
17+
askSelected: [][]bool{
18+
{true, false}, // 单选:OAuth 2.0
19+
{true, false, true}, // 多选:缓存 + 重试
20+
},
21+
}
22+
23+
got := m.askRecord(false)
24+
if !strings.Contains(got, "❓ 用哪种认证方式? → **OAuth 2.0**") {
25+
t.Errorf("single-select line missing, got:\n%s", got)
26+
}
27+
if !strings.Contains(got, "❓ 启用哪些特性? → **缓存、重试**") {
28+
t.Errorf("multi-select line missing, got:\n%s", got)
29+
}
30+
31+
// 跳过:每题都标注「已跳过」,不暴露任何选项。
32+
skip := m.askRecord(true)
33+
if strings.Count(skip, "(已跳过)") != 2 {
34+
t.Errorf("skipped record should mark every question, got:\n%s", skip)
35+
}
36+
if strings.Contains(skip, "OAuth") {
37+
t.Errorf("skipped record must not leak selections, got:\n%s", skip)
38+
}
39+
40+
// 一题都没选时落到「(未选)」兜底,不会 panic / 空答案。
41+
none := model{
42+
askQuestions: []agent.AskQuestion{{Question: "随便选", Options: []agent.AskOption{{Label: "A"}}}},
43+
askSelected: [][]bool{{false}},
44+
}
45+
if r := none.askRecord(false); !strings.Contains(r, "(未选)") {
46+
t.Errorf("no-selection fallback missing, got:\n%s", r)
47+
}
48+
}

tui/model.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,17 +1919,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
19191919
m.refreshViewport()
19201920
return m, nil
19211921
}
1922+
// 折叠成 kindSystem 档案段留在 scrollback:问题 + 已选答案,作答有痕(issue #134)。
1923+
m.chatContent.Open(kindSystem, m.askRecord(false))
19221924
m.askCh <- m.buildAskAnswer()
19231925
m.askPending = false
19241926
m.broadcast(web.Event{Kind: "ask_resolved"})
19251927
m.refreshViewport()
19261928
return m, func() tea.Msg { return reviewResultMsg{} }
19271929
case "esc", "ctrl+c":
1930+
m.chatContent.Open(kindSystem, m.askRecord(true))
19281931
m.askCh <- ""
19291932
m.askPending = false
19301933
m.broadcast(web.Event{Kind: "ask_resolved"})
19311934
m.refreshViewport()
19321935
return m, func() tea.Msg { return reviewResultMsg{} }
1936+
case "pgup", "pgdown", "pageup", "pagedown", "home", "end", "ctrl+u", "ctrl+d":
1937+
// 卡片已内联进对话流:翻页/顶底键照常滚动 chat 回看历史,不被选项交互吞掉(issue #134)。
1938+
var c tea.Cmd
1939+
m.chatViewport, c = m.chatViewport.Update(msg)
1940+
return m, c
19331941
}
19341942
return m, nil
19351943
}
@@ -3801,6 +3809,11 @@ func (m *model) renderChatBaseContent(w int) string {
38013809
if m.dockerPulling {
38023810
content += "\n" + dockerPullText(m.dockerPullImage, m.dockerPullDots)
38033811
}
3812+
// AskUser 选择题:待答时把交互卡片挂在对话区末尾(同 plan/docker 套路),不再用居中浮层。
3813+
// 这样卡片随会话一起滚动,用户可 PgUp/滚轮回看历史;答完后会折叠成 kindSystem 档案段留痕(issue #134)。
3814+
if m.askPending {
3815+
content += "\n" + m.askUserBlock()
3816+
}
38043817
// 思考动画不再画在 chat 末尾 —— 已统一移到输入框上方的活动状态行(statusFooterLine),
38053818
// 避免一次 thinking 出现在两个地方。
38063819
return content

tui/view.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,8 @@ func (m model) View() tea.View {
364364
if m.reviewPending {
365365
mainUI = overlayCentered(mainUI, m.reviewBlock(), m.width, m.height)
366366
}
367-
if m.askPending {
368-
mainUI = overlayCentered(mainUI, m.askUserBlock(), m.width, m.height)
369-
}
367+
// AskUser 选择题不再用居中浮层:卡片已内联进对话流末尾(见 renderChatBaseContent),
368+
// 随会话一起滚动,用户可 PgUp/滚轮回看历史,作答后折叠成档案留痕(issue #134)。
370369
if m.showLangModal {
371370
mainUI = overlayCentered(mainUI, m.langModalBlock(), m.width, m.height)
372371
}

web/static/app.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ const I18N = {
2323
'ask.multi': '(多选)',
2424
'ask.submit': '提交',
2525
'ask.cancel': '取消',
26+
'ask.skipped': '(已跳过)',
27+
'ask.none': '(未选)',
28+
'ask.join': '、',
2629
'footer.thinking': '思考中',
2730
'footer.streaming': '输出中',
2831
'footer.tool': '调用工具',
@@ -98,6 +101,9 @@ const I18N = {
98101
'ask.multi': '(multiple)',
99102
'ask.submit': 'Submit',
100103
'ask.cancel': 'Cancel',
104+
'ask.skipped': '(skipped)',
105+
'ask.none': '(none)',
106+
'ask.join': ', ',
101107
'footer.thinking': 'Thinking',
102108
'footer.streaming': 'Responding',
103109
'footer.tool': 'Running tool',
@@ -434,15 +440,31 @@ createApp({
434440
this.askSel[qi] = this.askSel[qi].map((_, i) => i === oi);
435441
}
436442
},
443+
// pushAskRecord 把作答折叠成一条 ask-record 消息留在对话流(对齐 TUI 的 kindSystem 档案段,issue #134):
444+
// 每题一行「❓ 问题 → **已选答案**」(多选用顿号连接);skipped=true 记「(已跳过)」。
445+
// 必须在清空 askSel 之前调用 —— 它要读 askSel 取选中项。
446+
pushAskRecord(questions, skipped) {
447+
const lines = (questions || []).map((q, qi) => {
448+
if (skipped) return `❓ ${q.question}${this.t('ask.skipped')}`;
449+
const sel = (q.options || [])
450+
.filter((_, oi) => this.askSel[qi] && this.askSel[qi][oi])
451+
.map(opt => opt.label);
452+
const ans = sel.length ? sel.join(this.t('ask.join')) : this.t('ask.none');
453+
return `❓ ${q.question} → **${ans}**`;
454+
});
455+
if (lines.length) this.messages.push({ role: 'ask-record', content: lines.join('\n\n') });
456+
},
437457
async submitAsk() {
438458
// 组装成与终端一致的格式:{"answers":[{question, selected:[value...]}]}
439-
const answers = (this.askPending || []).map((q, qi) => ({
459+
const questions = this.askPending || [];
460+
const answers = questions.map((q, qi) => ({
440461
question: q.question,
441462
selected: (q.options || [])
442463
.filter((_, oi) => this.askSel[qi] && this.askSel[qi][oi])
443464
.map(opt => opt.value || opt.label),
444465
}));
445466
const answer = JSON.stringify({ answers });
467+
this.pushAskRecord(questions, false); // 留痕:先于清空 askSel
446468
this.askPending = null;
447469
this.askSel = [];
448470
await fetch('/api/ask-answer', {
@@ -452,6 +474,7 @@ createApp({
452474
}).catch(() => {});
453475
},
454476
async cancelAsk() {
477+
this.pushAskRecord(this.askPending, true); // 取消也留痕:先于清空 askSel
455478
this.askPending = null;
456479
this.askSel = [];
457480
await fetch('/api/ask-answer', {

web/static/index.html

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@
7171
<div class="role">{{ t('you') }}</div>
7272
<div class="bubble user-text">{{ m.content }}</div>
7373
</template>
74+
<!-- AskUser 作答折叠档案:问题 + 已选答案,留在对话流里(issue #134) -->
75+
<template v-else-if="m.role === 'ask-record'">
76+
<div class="bubble ask-record md" v-html="render(m.content)"></div>
77+
</template>
7478
<!-- assistant:仅当有实际内容时才出气泡,空消息不渲染空卡片 -->
7579
<template v-else-if="m.content && m.content.trim()">
7680
<div class="role">deepx</div>
@@ -104,6 +108,22 @@
104108
</div>
105109
</div>
106110
</div>
111+
<!-- AskUser 选择题:内联进对话流末尾,随会话一起滚动,不再用居中浮层(issue #134) -->
112+
<div v-if="askPending" class="chat-ask">
113+
<div class="ask-title">{{ t('ask.title') }}</div>
114+
<div v-for="(q, qi) in askPending" :key="qi" class="ask-q">
115+
<div class="ask-question">{{ q.question }}<span class="ask-kind">{{ q.multiple ? t('ask.multi') : t('ask.single') }}</span></div>
116+
<label v-for="(opt, oi) in q.options" :key="oi" class="ask-opt" :class="{ on: askSel[qi] && askSel[qi][oi] }">
117+
<input :type="q.multiple ? 'checkbox' : 'radio'" :name="'q'+qi"
118+
:checked="askSel[qi] && askSel[qi][oi]" @change="toggleAsk(qi, oi, q.multiple)">
119+
<span>{{ opt.label }}</span>
120+
</label>
121+
</div>
122+
<div class="ask-actions">
123+
<button class="reject" @click="cancelAsk()">{{ t('ask.cancel') }}</button>
124+
<button class="approve" @click="submitAsk()">{{ t('ask.submit') }}</button>
125+
</div>
126+
</div>
107127
</div>
108128

109129
<div class="controls-bar">
@@ -227,25 +247,6 @@
227247
</div>
228248
</div>
229249

230-
<!-- AskUser 选择题弹层 -->
231-
<div v-if="askPending" class="review-mask">
232-
<div class="review-box ask-box">
233-
<div class="review-title">{{ t('ask.title') }}</div>
234-
<div v-for="(q, qi) in askPending" :key="qi" class="ask-q">
235-
<div class="ask-question">{{ q.question }}<span class="ask-kind">{{ q.multiple ? t('ask.multi') : t('ask.single') }}</span></div>
236-
<label v-for="(opt, oi) in q.options" :key="oi" class="ask-opt" :class="{ on: askSel[qi] && askSel[qi][oi] }">
237-
<input :type="q.multiple ? 'checkbox' : 'radio'" :name="'q'+qi"
238-
:checked="askSel[qi] && askSel[qi][oi]" @change="toggleAsk(qi, oi, q.multiple)">
239-
<span>{{ opt.label }}</span>
240-
</label>
241-
</div>
242-
<div class="review-actions">
243-
<button class="reject" @click="cancelAsk()">{{ t('ask.cancel') }}</button>
244-
<button class="approve" @click="submitAsk()">{{ t('ask.submit') }}</button>
245-
</div>
246-
</div>
247-
</div>
248-
249250
<!-- MCP 管理弹层 -->
250251
<div v-if="mcpShow" class="review-mask" @click.self="mcpShow=false">
251252
<div class="review-box mgr-box">

web/static/styles.css

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,21 @@ body {
217217
.review-actions .approve { background: var(--green); color: #ffffff; }
218218
.review-actions .reject { background: var(--red); color: #ffffff; }
219219

220-
/* AskUser 选择题弹层 */
221-
.ask-box { max-height: 80vh; overflow: auto; }
220+
/* AskUser 选择题:内联进对话流的卡片(不再浮层弹窗,issue #134) */
221+
.chat-ask {
222+
margin: 8px 0; padding: 14px 16px; max-width: 820px;
223+
background: var(--bg2); border: 1px solid var(--accent); border-radius: 10px;
224+
}
225+
.ask-title { font-weight: 700; color: var(--accent); margin-bottom: 10px; }
226+
.ask-actions { display: flex; gap: 12px; margin-top: 14px; justify-content: flex-end; }
227+
.ask-actions button { border: none; border-radius: 8px; padding: 8px 22px; font-weight: 600; cursor: pointer; }
228+
.ask-actions .approve { background: var(--green); color: #ffffff; }
229+
.ask-actions .reject { background: var(--red); color: #ffffff; }
230+
/* 作答折叠档案:留在对话流的轻量记录,弱化处理,区别于进行中的卡片 */
231+
.bubble.ask-record {
232+
background: var(--bg2); border: 1px solid var(--border); border-left: 3px solid var(--accent);
233+
color: var(--dim); font-size: 13px;
234+
}
222235
.ask-q { margin-bottom: 16px; }
223236
.ask-question { font-weight: 600; margin-bottom: 8px; }
224237
.ask-kind { color: var(--dim); font-weight: 400; font-size: 12px; margin-left: 6px; }

0 commit comments

Comments
 (0)