@linxin666/dsh-remote-web-ui 0.1.17 → 0.1.19

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 (56) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +2 -2
  4. package/lib/client.js +82 -66
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +18 -18
  7. package/lib/mobile.js +649 -45
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +11 -5
  12. package/lib/types/client/RemoteSettingsCard.d.ts +5 -0
  13. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/RemoteSettingsCard.js +7 -0
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/index.js +13 -7
  17. package/lib/types/client/settings-form.d.ts +8 -0
  18. package/lib/types/client/settings-form.d.ts.map +1 -1
  19. package/lib/types/client/settings-form.js +15 -1
  20. package/lib/types/gate.d.ts +4 -9
  21. package/lib/types/gate.d.ts.map +1 -1
  22. package/lib/types/gate.js +5 -32
  23. package/lib/types/loopback.d.ts +25 -0
  24. package/lib/types/loopback.d.ts.map +1 -0
  25. package/lib/types/loopback.js +68 -0
  26. package/lib/types/mobile/markdown.d.ts +26 -0
  27. package/lib/types/mobile/markdown.d.ts.map +1 -0
  28. package/lib/types/mobile/markdown.js +272 -0
  29. package/lib/types/mobile/markdown.test.d.ts +2 -0
  30. package/lib/types/mobile/markdown.test.d.ts.map +1 -0
  31. package/lib/types/mobile/markdown.test.js +67 -0
  32. package/lib/types/mobile/messages.d.ts +14 -0
  33. package/lib/types/mobile/messages.d.ts.map +1 -1
  34. package/lib/types/mobile/messages.js +54 -2
  35. package/lib/types/mobile/messages.test.js +99 -0
  36. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  37. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  38. package/lib/types/mobile/mobile-styles.js +167 -0
  39. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  40. package/lib/types/mobile/views/ChatView.js +81 -5
  41. package/lib/types/mobile/views/ChatView.test.js +105 -0
  42. package/package.json +1 -1
  43. package/src/client/PluginSettingsCard.tsx +50 -47
  44. package/src/client/RemoteSettingsCard.tsx +8 -0
  45. package/src/client/index.ts +13 -7
  46. package/src/client/settings-card.module.css +9 -0
  47. package/src/client/settings-form.ts +15 -1
  48. package/src/gate.ts +5 -30
  49. package/src/loopback.ts +63 -0
  50. package/src/mobile/markdown.test.ts +77 -0
  51. package/src/mobile/markdown.ts +278 -0
  52. package/src/mobile/messages.test.ts +106 -0
  53. package/src/mobile/messages.ts +66 -2
  54. package/src/mobile/mobile-styles.ts +167 -0
  55. package/src/mobile/views/ChatView.test.tsx +121 -0
  56. package/src/mobile/views/ChatView.tsx +164 -6
@@ -393,6 +393,85 @@ body {
393
393
  cursor: pointer;
394
394
  }
395
395
 
396
+ /* Assistant markdown content (GFM subset; renderer in markdown.ts). */
397
+ .chat-md {
398
+ white-space: normal;
399
+ }
400
+ .chat-md-collapsed {
401
+ max-height: 45vh;
402
+ overflow: hidden;
403
+ position: relative;
404
+ }
405
+ .chat-md-collapsed::after {
406
+ content: '';
407
+ position: absolute;
408
+ left: 0;
409
+ right: 0;
410
+ bottom: 0;
411
+ height: 28px;
412
+ background: linear-gradient(transparent, var(--m-bg-raised));
413
+ pointer-events: none;
414
+ }
415
+ .chat-md-body p { margin: 0 0 8px; }
416
+ .chat-md-body p:last-child { margin-bottom: 0; }
417
+ .chat-md-body h1, .chat-md-body h2, .chat-md-body h3,
418
+ .chat-md-body h4, .chat-md-body h5, .chat-md-body h6 {
419
+ margin: 12px 0 6px;
420
+ font-weight: 650;
421
+ line-height: 1.3;
422
+ }
423
+ .chat-md-body h1 { font-size: 1.35em; }
424
+ .chat-md-body h2 { font-size: 1.25em; }
425
+ .chat-md-body h3 { font-size: 1.15em; }
426
+ .chat-md-body h4, .chat-md-body h5, .chat-md-body h6 { font-size: 1.05em; }
427
+ .chat-md-body code {
428
+ font-family: var(--m-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
429
+ font-size: 0.9em;
430
+ background: var(--m-accent-soft);
431
+ padding: 1px 5px;
432
+ border-radius: 4px;
433
+ }
434
+ .chat-md-body pre {
435
+ margin: 8px 0;
436
+ padding: 10px;
437
+ background: #101726;
438
+ color: #dbe4f5;
439
+ border-radius: 8px;
440
+ overflow-x: auto;
441
+ font-size: 12.5px;
442
+ line-height: 1.5;
443
+ }
444
+ .chat-md-body pre code {
445
+ background: transparent;
446
+ padding: 0;
447
+ color: inherit;
448
+ }
449
+ .chat-md-body ul, .chat-md-body ol { margin: 4px 0 8px; padding-left: 22px; }
450
+ .chat-md-body li { margin: 2px 0; }
451
+ .chat-md-body blockquote {
452
+ margin: 8px 0;
453
+ padding: 4px 10px;
454
+ border-left: 3px solid var(--m-accent);
455
+ background: var(--m-accent-soft);
456
+ border-radius: 0 6px 6px 0;
457
+ color: var(--m-text-secondary);
458
+ }
459
+ .chat-md-body table {
460
+ margin: 8px 0;
461
+ border-collapse: collapse;
462
+ display: block;
463
+ overflow-x: auto;
464
+ font-size: 13px;
465
+ }
466
+ .chat-md-body th, .chat-md-body td {
467
+ border: 1px solid var(--m-border);
468
+ padding: 4px 8px;
469
+ }
470
+ .chat-md-body th { background: var(--m-accent-soft); }
471
+ .chat-md-body a { color: var(--m-accent); }
472
+ .chat-md-body hr { border: none; border-top: 1px solid var(--m-border); margin: 10px 0; }
473
+ .chat-md-body img { max-width: 100%; border-radius: 8px; }
474
+
396
475
  /* ── message disclosures (reasoning / tools) ─────────────────────────── */
397
476
 
398
477
  .chat-disclosure {
@@ -824,11 +903,97 @@ body {
824
903
  .chat-disclosure-head:focus-visible,
825
904
  .sheet-option:focus-visible,
826
905
  .sheet-confirm-danger:focus-visible,
906
+ .sheet-toggle-switch:focus-visible,
827
907
  .chat-input:focus-visible {
828
908
  outline: none;
829
909
  box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
830
910
  }
831
911
 
912
+ /* ── chat context usage chip (chat-tools row) ───────────────────────── */
913
+
914
+ .chat-context {
915
+ flex: none;
916
+ align-self: center;
917
+ padding: 2px 10px;
918
+ border: 1px solid var(--m-border);
919
+ border-radius: 999px;
920
+ background: color-mix(in srgb, var(--m-text-tertiary) 10%, transparent);
921
+ color: var(--m-text-tertiary);
922
+ font-size: 11px;
923
+ line-height: 1.6;
924
+ white-space: nowrap;
925
+ }
926
+
927
+ .chat-context-warn {
928
+ border-color: var(--m-danger);
929
+ background: color-mix(in srgb, var(--m-danger) 14%, transparent);
930
+ color: var(--m-danger);
931
+ }
932
+
933
+ /* ── display sheet toggle rows ──────────────────────────────────────── */
934
+
935
+ .sheet-toggle-row {
936
+ display: flex;
937
+ align-items: center;
938
+ gap: 12px;
939
+ padding: 12px 8px;
940
+ border-bottom: 1px solid var(--m-border);
941
+ }
942
+
943
+ .sheet-toggle-row:last-child {
944
+ border-bottom: none;
945
+ }
946
+
947
+ .sheet-toggle-copy {
948
+ flex: 1;
949
+ min-width: 0;
950
+ display: flex;
951
+ flex-direction: column;
952
+ gap: 2px;
953
+ }
954
+
955
+ .sheet-toggle-title {
956
+ font-size: 14px;
957
+ }
958
+
959
+ .sheet-toggle-desc {
960
+ color: var(--m-text-tertiary);
961
+ font-size: 12px;
962
+ line-height: 1.4;
963
+ }
964
+
965
+ .sheet-toggle-switch {
966
+ position: relative;
967
+ flex: none;
968
+ width: 44px;
969
+ height: 26px;
970
+ border: none;
971
+ border-radius: 999px;
972
+ background: var(--m-border);
973
+ cursor: pointer;
974
+ transition: background-color 0.15s ease;
975
+ }
976
+
977
+ .sheet-toggle-switch-on {
978
+ background: var(--m-accent);
979
+ }
980
+
981
+ .sheet-toggle-switch-knob {
982
+ position: absolute;
983
+ top: 3px;
984
+ left: 3px;
985
+ width: 20px;
986
+ height: 20px;
987
+ border-radius: 50%;
988
+ background: #fff;
989
+ box-shadow: 0 1px 2px rgba(16, 24, 40, 0.2);
990
+ transition: transform 0.15s ease;
991
+ }
992
+
993
+ .sheet-toggle-switch-on .sheet-toggle-switch-knob {
994
+ transform: translateX(18px);
995
+ }
996
+
832
997
  /* Accessibility: collapse every interaction/enter animation for
833
998
  reduced-motion users; surface layout itself stays static. */
834
999
  @media (prefers-reduced-motion: reduce) {
@@ -848,6 +1013,8 @@ body {
848
1013
  .sheet,
849
1014
  .sheet-option,
850
1015
  .sheet-confirm-danger,
1016
+ .sheet-toggle-switch,
1017
+ .sheet-toggle-switch-knob,
851
1018
  .chat-input {
852
1019
  animation: none;
853
1020
  transition: none;
@@ -518,3 +518,124 @@ describe('ChatView scrolling', () => {
518
518
  await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore) })
519
519
  })
520
520
  })
521
+
522
+ describe('ChatView display toggles and context usage', () => {
523
+ /** jsdom in this setup ships a bare localStorage object; install a real fake. */
524
+ const makeStorage = (): Storage => {
525
+ const map = new Map<string, string>()
526
+ return {
527
+ getItem: (key: string) => map.get(key) ?? null,
528
+ setItem: (key: string, value: string) => { map.set(key, value) },
529
+ removeItem: (key: string) => { map.delete(key) },
530
+ clear: () => { map.clear() },
531
+ key: (index: number) => [...map.keys()][index] ?? null,
532
+ get length() { return map.size },
533
+ } as Storage
534
+ }
535
+
536
+ /** A user/message whose source.kind is 'plugin' (injected system message). */
537
+ const systemEvents = (): Array<{ event: WireEvent }> => [
538
+ makeEntry('user/message', {
539
+ id: 'u-plugin',
540
+ role: 'user',
541
+ content: [{ type: 'text', text: '系统注入消息' }],
542
+ source: { kind: 'plugin', name: 'react-extension' },
543
+ }, 0),
544
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '普通消息' }] }, 1),
545
+ ]
546
+
547
+ const toolEvents = (): Array<{ event: WireEvent }> => [
548
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: '改文件' }] }, 0),
549
+ makeEntry('assistant/message', {
550
+ turn: 0,
551
+ step: 0,
552
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: '完成' }] },
553
+ }, 1),
554
+ makeEntry('tool/call', { turn: 0, step: 0, callId: 'c1', name: 'bash', arguments: '{}' }, 2),
555
+ ]
556
+
557
+ beforeEach(() => {
558
+ vi.stubGlobal('localStorage', makeStorage())
559
+ })
560
+
561
+ afterEach(() => {
562
+ vi.unstubAllGlobals()
563
+ })
564
+
565
+ it('hides injected user messages by default and reveals them via the display sheet', async () => {
566
+ loadHistoryMock.mockResolvedValue(historyPage(systemEvents()))
567
+ render(<ChatView session={session} onBack={() => {}} />)
568
+
569
+ // The plugin-injected message is hidden by default; the real user message shows.
570
+ expect(await screen.findByText('普通消息')).toBeTruthy()
571
+ expect(screen.queryByText('系统注入消息')).toBeNull()
572
+
573
+ // Open the display sheet and flip the system-message switch on.
574
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }))
575
+ const systemSwitch = await screen.findByRole('switch', { name: '系统提示词' })
576
+ expect(systemSwitch.getAttribute('aria-checked')).toBe('false')
577
+ fireEvent.click(systemSwitch)
578
+ expect(await screen.findByText('系统注入消息')).toBeTruthy()
579
+
580
+ // Flip it back off: the injected row disappears again.
581
+ const systemSwitchAfter = screen.getByRole('switch', { name: '系统提示词' })
582
+ expect(systemSwitchAfter.getAttribute('aria-checked')).toBe('true')
583
+ fireEvent.click(systemSwitchAfter)
584
+ expect(screen.queryByText('系统注入消息')).toBeNull()
585
+ })
586
+
587
+ it('hides the tool disclosure when the tool-call toggle is off', async () => {
588
+ loadHistoryMock.mockResolvedValue(historyPage(toolEvents()))
589
+ render(<ChatView session={session} onBack={() => {}} />)
590
+
591
+ // Tool disclosure visible by default.
592
+ expect(await screen.findByRole('button', { name: /工具/ })).toBeTruthy()
593
+
594
+ fireEvent.click(screen.getByRole('button', { name: /显示/ }))
595
+ const toolSwitch = await screen.findByRole('switch', { name: '工具调用' })
596
+ expect(toolSwitch.getAttribute('aria-checked')).toBe('true')
597
+ fireEvent.click(toolSwitch)
598
+
599
+ // The disclosure is gone while reasoning/text remain.
600
+ expect(screen.queryByRole('button', { name: /工具/ })).toBeNull()
601
+ expect(screen.getByText('完成')).toBeTruthy()
602
+ })
603
+
604
+ it('renders the context usage chip from request/context plus assistant usage', async () => {
605
+ loadHistoryMock.mockResolvedValue(historyPage([
606
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
607
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
608
+ makeEntry('assistant/message', {
609
+ turn: 0,
610
+ step: 0,
611
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
612
+ usage: { inputTokens: 30_000, outputTokens: 1_000 },
613
+ }, 2),
614
+ ]))
615
+ render(<ChatView session={session} onBack={() => {}} />)
616
+ expect(await screen.findByText('上下文 30%')).toBeTruthy()
617
+ })
618
+
619
+ it('adds the warn class when context usage is at or above 80%', async () => {
620
+ loadHistoryMock.mockResolvedValue(historyPage([
621
+ makeEntry('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
622
+ makeEntry('user/message', { id: 'u-1', role: 'user', content: [{ type: 'text', text: 'hi' }] }, 1),
623
+ makeEntry('assistant/message', {
624
+ turn: 0,
625
+ step: 0,
626
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
627
+ usage: { inputTokens: 80_000, outputTokens: 0 },
628
+ }, 2),
629
+ ]))
630
+ render(<ChatView session={session} onBack={() => {}} />)
631
+ const chip = await screen.findByText('上下文 80%')
632
+ expect(chip.className).toContain('chat-context-warn')
633
+ })
634
+
635
+ it('renders no context chip when there is no usage/context data', async () => {
636
+ loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
637
+ render(<ChatView session={session} onBack={() => {}} />)
638
+ await screen.findByText('已完成修改')
639
+ expect(screen.queryByText(/上下文/)).toBeNull()
640
+ })
641
+ })
@@ -11,13 +11,14 @@
11
11
  * permission pickers, both as bottom sheets.
12
12
  */
13
13
 
14
- import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
14
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
15
15
  import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
16
16
  import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
17
17
  import { loadHistory, prompt, type SessionView } from './App.tsx'
18
18
  import { errorText, formatTime, staleHostHint } from './App.tsx'
19
19
  import { fetchMobilePreferences, models, selectModel, sendCommand } from '../api.ts'
20
20
  import { foldEvents, type RenderMessage, type ToolCallInfo, type WireEvent } from '../messages.ts'
21
+ import { renderMarkdown } from '../markdown.ts'
21
22
  import { MuxClient } from '../mux.ts'
22
23
  import { ThemeToggle } from '../theme-toggle.tsx'
23
24
 
@@ -36,6 +37,31 @@ export interface ChatViewProps {
36
37
  */
37
38
  export const MAX_TAIL_BUFFER_EVENTS = 500
38
39
 
40
+ /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
41
+ const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls'
42
+ /** localStorage key for the injected-system-message display toggle. */
43
+ const SHOW_SYSTEM_MESSAGES_KEY = 'dsh.mobile.showSystemMessages'
44
+
45
+ /** Read a boolean from localStorage defensively; falls back to the default. */
46
+ function readStoredBoolean(key: string, fallback: boolean): boolean {
47
+ try {
48
+ const raw = localStorage.getItem(key)
49
+ if (raw === null) return fallback
50
+ return raw === '1' || raw.toLowerCase() === 'true'
51
+ } catch {
52
+ return fallback
53
+ }
54
+ }
55
+
56
+ /** Persist a boolean toggle; storage failures are ignored (feature stays non-persistent). */
57
+ function writeStoredBoolean(key: string, value: boolean): void {
58
+ try {
59
+ localStorage.setItem(key, value ? '1' : '0')
60
+ } catch {
61
+ /* quota / privacy mode: non-persistent is acceptable */
62
+ }
63
+ }
64
+
39
65
  /** Extract the raw event from one history entry (the fold consumes events only). */
40
66
  function eventOf(entry: { event: WireEvent }): WireEvent {
41
67
  return entry.event
@@ -126,7 +152,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
126
152
  /** The current model selection for the toolbar chip (best-effort label). */
127
153
  const [currentModel, setCurrentModel] = useState<{ provider: string; model: string; reasoningEffort?: string } | undefined>(undefined)
128
154
  /** Which bottom sheet is open. */
129
- const [sheet, setSheet] = useState<'model' | 'permission' | null>(null)
155
+ const [sheet, setSheet] = useState<'model' | 'permission' | 'display' | null>(null)
156
+ /** Show tool-call disclosures (default on, persisted on the /m origin). */
157
+ const [showToolCalls, setShowToolCalls] = useState<boolean>(() => readStoredBoolean(SHOW_TOOL_CALLS_KEY, true))
158
+ /** Show injected system messages (default off, persisted on the /m origin). */
159
+ const [showSystemMessages, setShowSystemMessages] = useState<boolean>(() => readStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, false))
130
160
  /**
131
161
  * Composer preference from the plugin's host settings (default true keeps
132
162
  * the legacy Enter-to-send behavior until the preference loads).
@@ -334,6 +364,23 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
334
364
  : permissions.options.find(option => option.value === permissions.currentValue)?.name
335
365
  ?? displayName(permissions.currentValue)
336
366
 
367
+ // Context usage chip: the most recent assistant message carrying both usage and
368
+ // a positive context window drives the percentage. Scanned from the end so a
369
+ // newer answer (whose usage may be the last one reported) takes precedence.
370
+ const contextUsage = useMemo(() => {
371
+ for (let index = messages.length - 1; index >= 0; index--) {
372
+ const message = messages[index]
373
+ const usage = message.usage
374
+ if (message.kind !== 'assistant' || usage === undefined) continue
375
+ const window = message.contextWindow
376
+ if (window === undefined || window <= 0) continue
377
+ const tokens = usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
378
+ const pct = Math.round(tokens / window * 100)
379
+ return { pct }
380
+ }
381
+ return undefined
382
+ }, [messages])
383
+
337
384
  return (
338
385
  <div className="chat">
339
386
  <header className="mobile-header">
@@ -348,7 +395,14 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
348
395
  {loading ? '加载中…' : '加载更早的消息'}
349
396
  </button>
350
397
  )}
351
- {messages.map(message => <MessageRow key={message.id} message={message} />)}
398
+ {messages.map(message => (
399
+ <MessageRow
400
+ key={message.id}
401
+ message={message}
402
+ showToolCalls={showToolCalls}
403
+ showSystemMessages={showSystemMessages}
404
+ />
405
+ ))}
352
406
  {loading && messages.length === 0 && <p className="chat-typing">加载中…</p>}
353
407
  {!loading && messages.length === 0 && <p className="chat-typing">还没有消息,发一句话开始吧</p>}
354
408
  </div>
@@ -358,6 +412,10 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
358
412
  <span className="chat-chip-value">{modelLabel}</span>
359
413
  <span className="chat-chip-chevron" aria-hidden>›</span>
360
414
  </button>
415
+ <button type="button" className="chat-chip" onClick={() => { setSheet('display') }} aria-haspopup="dialog">
416
+ <span className="chat-chip-label">显示</span>
417
+ <span className="chat-chip-chevron" aria-hidden>›</span>
418
+ </button>
361
419
  {permissionLabel !== undefined && (
362
420
  <button type="button" className="chat-chip" onClick={() => { setSheet('permission') }} aria-haspopup="dialog">
363
421
  <span className="chat-chip-label">权限</span>
@@ -365,6 +423,11 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
365
423
  <span className="chat-chip-chevron" aria-hidden>›</span>
366
424
  </button>
367
425
  )}
426
+ {contextUsage !== undefined && (
427
+ <div className={"chat-context" + (contextUsage.pct >= 80 ? " chat-context-warn" : "")}>
428
+ 上下文 {contextUsage.pct}%
429
+ </div>
430
+ )}
368
431
  </div>
369
432
  <div className="chat-inputbar">
370
433
  <textarea
@@ -403,6 +466,15 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
403
466
  onClose={() => { setSheet(null) }}
404
467
  />
405
468
  )}
469
+ {sheet === 'display' && (
470
+ <DisplaySheet
471
+ showToolCalls={showToolCalls}
472
+ showSystemMessages={showSystemMessages}
473
+ onToolCalls={(value) => { setShowToolCalls(value); writeStoredBoolean(SHOW_TOOL_CALLS_KEY, value) }}
474
+ onSystemMessages={(value) => { setShowSystemMessages(value); writeStoredBoolean(SHOW_SYSTEM_MESSAGES_KEY, value) }}
475
+ onClose={() => { setSheet(null) }}
476
+ />
477
+ )}
406
478
  </div>
407
479
  )
408
480
  }
@@ -410,16 +482,30 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
410
482
  /* ── message rows ─────────────────────────────────────────────────────── */
411
483
 
412
484
  /** One rendered message row (user bubble or assistant bubble with folds). */
413
- function MessageRow({ message }: { message: RenderMessage }) {
485
+ function MessageRow({ message, showToolCalls, showSystemMessages }: {
486
+ message: RenderMessage
487
+ showToolCalls: boolean
488
+ showSystemMessages: boolean
489
+ }) {
490
+ // Injected user messages (sourceKind defined and not 'user') hide behind
491
+ // the system-message toggle. Assistant messages are never hidden.
492
+ if (message.kind === 'user'
493
+ && message.sourceKind !== undefined
494
+ && message.sourceKind !== 'user'
495
+ && !showSystemMessages) {
496
+ return null
497
+ }
414
498
  return (
415
499
  <div className={`chat-msg chat-msg-${message.kind}${message.pending === true ? ' chat-msg-pending' : ''}${message.failed === true ? ' chat-msg-failed' : ''}`}>
416
500
  {message.kind === 'assistant' && message.reasoning !== undefined && message.reasoning !== '' && (
417
501
  <ReasoningDisclosure text={message.reasoning} pending={message.pending === true} />
418
502
  )}
419
- {message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (
503
+ {showToolCalls && message.kind === 'assistant' && message.tools !== undefined && message.tools.length > 0 && (
420
504
  <ToolDisclosure tools={message.tools} />
421
505
  )}
422
- <CollapsibleText text={message.text} />
506
+ {message.kind === 'assistant'
507
+ ? <MarkdownText text={message.text} />
508
+ : <CollapsibleText text={message.text} />}
423
509
  {message.failed === true && <span className="chat-msg-failtag">本次回复失败</span>}
424
510
  <span className="chat-msg-time">{formatTime(message.time)}</span>
425
511
  </div>
@@ -478,6 +564,30 @@ function ToolDisclosure({ tools }: { tools: ToolCallInfo[] }) {
478
564
  )
479
565
  }
480
566
 
567
+ /**
568
+ * Assistant text rendered as GFM markdown (escape-first, protocol
569
+ * allow-list — see markdown.ts). Long replies collapse by clamping the
570
+ * rendered block height instead of slicing the source, so half-cut code
571
+ * fences or tables never leak malformed markup into the DOM. User
572
+ * messages stay plain text (CollapsibleText).
573
+ */
574
+ function MarkdownText({ text }: { text: string }) {
575
+ const [open, setOpen] = useState(false)
576
+ const html = useMemo(() => renderMarkdown(text), [text])
577
+ const long = text.length > LONG_TEXT_LIMIT
578
+ const collapsed = long && !open
579
+ return (
580
+ <div className={'chat-msg-text chat-md' + (collapsed ? ' chat-md-collapsed' : '')}>
581
+ <div className="chat-md-body" dangerouslySetInnerHTML={{ __html: html }} />
582
+ {long && (
583
+ <button type="button" className="chat-msg-toggle" onClick={() => { setOpen(value => !value) }}>
584
+ {open ? '收起' : '展开全文(' + text.length + ' 字)'}
585
+ </button>
586
+ )}
587
+ </div>
588
+ )
589
+ }
590
+
481
591
  /** Long assistant text collapses behind an explicit expand toggle. */
482
592
  function CollapsibleText({ text }: { text: string }) {
483
593
  const [open, setOpen] = useState(false)
@@ -756,3 +866,51 @@ function PermissionSheet({ sessionId, value, onChanged, onClose }: {
756
866
  </Sheet>
757
867
  )
758
868
  }
869
+
870
+ /** The display-options sheet: tool calls and injected system messages toggles. */
871
+ function DisplaySheet({ showToolCalls, showSystemMessages, onToolCalls, onSystemMessages, onClose }: {
872
+ showToolCalls: boolean
873
+ showSystemMessages: boolean
874
+ onToolCalls(value: boolean): void
875
+ onSystemMessages(value: boolean): void
876
+ onClose(): void
877
+ }) {
878
+ return (
879
+ <Sheet title="显示" onClose={onClose}>
880
+ <div role="group" aria-label="显示选项">
881
+ <div className="sheet-toggle-row">
882
+ <div className="sheet-toggle-copy">
883
+ <span className="sheet-toggle-title">工具调用</span>
884
+ <span className="sheet-toggle-desc">显示助手使用的工具调用</span>
885
+ </div>
886
+ <button
887
+ type="button"
888
+ role="switch"
889
+ aria-label="工具调用"
890
+ aria-checked={showToolCalls}
891
+ className={`sheet-toggle-switch${showToolCalls ? ' sheet-toggle-switch-on' : ''}`}
892
+ onClick={() => { onToolCalls(!showToolCalls) }}
893
+ >
894
+ <span className="sheet-toggle-switch-knob" aria-hidden />
895
+ </button>
896
+ </div>
897
+ <div className="sheet-toggle-row">
898
+ <div className="sheet-toggle-copy">
899
+ <span className="sheet-toggle-title">系统提示词</span>
900
+ <span className="sheet-toggle-desc">显示注入到对话中的系统消息</span>
901
+ </div>
902
+ <button
903
+ type="button"
904
+ role="switch"
905
+ aria-label="系统提示词"
906
+ aria-checked={showSystemMessages}
907
+ className={`sheet-toggle-switch${showSystemMessages ? ' sheet-toggle-switch-on' : ''}`}
908
+ onClick={() => { onSystemMessages(!showSystemMessages) }}
909
+ >
910
+ <span className="sheet-toggle-switch-knob" aria-hidden />
911
+ </button>
912
+ </div>
913
+ </div>
914
+ </Sheet>
915
+ )
916
+ }