npcsh 1.1.22__py3-none-any.whl → 1.1.23__py3-none-any.whl

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 (172) hide show
  1. npcsh/_state.py +272 -120
  2. npcsh/benchmark/npcsh_agent.py +77 -240
  3. npcsh/benchmark/templates/install-npcsh.sh.j2 +12 -4
  4. npcsh/config.py +5 -2
  5. npcsh/npc_team/alicanto.npc +4 -8
  6. npcsh/npc_team/corca.npc +5 -11
  7. npcsh/npc_team/frederic.npc +4 -6
  8. npcsh/npc_team/guac.npc +4 -4
  9. npcsh/npc_team/jinxs/lib/core/delegate.jinx +1 -1
  10. npcsh/npc_team/jinxs/lib/core/edit_file.jinx +1 -1
  11. npcsh/npc_team/jinxs/lib/core/sh.jinx +1 -1
  12. npcsh/npc_team/jinxs/lib/core/skill.jinx +59 -0
  13. npcsh/npc_team/jinxs/lib/utils/help.jinx +194 -10
  14. npcsh/npc_team/jinxs/lib/utils/init.jinx +528 -37
  15. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +0 -1
  16. npcsh/npc_team/jinxs/lib/utils/serve.jinx +938 -21
  17. npcsh-1.1.22.data/data/npcsh/npc_team/config_tui.jinx → npcsh/npc_team/jinxs/modes/config.jinx +1 -1
  18. npcsh/npc_team/jinxs/modes/convene.jinx +76 -3
  19. npcsh/npc_team/jinxs/modes/crond.jinx +818 -0
  20. npcsh/npc_team/jinxs/modes/plonk.jinx +76 -14
  21. npcsh/npc_team/jinxs/modes/roll.jinx +368 -55
  22. npcsh/npc_team/jinxs/modes/skills.jinx +621 -0
  23. npcsh/npc_team/jinxs/modes/yap.jinx +504 -30
  24. npcsh/npc_team/jinxs/skills/code-review/SKILL.md +45 -0
  25. npcsh/npc_team/jinxs/skills/debugging/SKILL.md +44 -0
  26. npcsh/npc_team/jinxs/skills/git-workflow.jinx +44 -0
  27. npcsh/npc_team/kadiefa.npc +4 -5
  28. npcsh/npc_team/npcsh.ctx +16 -0
  29. npcsh/npc_team/plonk.npc +5 -9
  30. npcsh/npc_team/sibiji.npc +13 -5
  31. npcsh/npcsh.py +1 -0
  32. npcsh/routes.py +0 -4
  33. npcsh/yap.py +22 -4
  34. npcsh-1.1.23.data/data/npcsh/npc_team/SKILL.md +44 -0
  35. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.npc +4 -8
  36. npcsh/npc_team/jinxs/modes/config_tui.jinx → npcsh-1.1.23.data/data/npcsh/npc_team/config.jinx +1 -1
  37. npcsh-1.1.23.data/data/npcsh/npc_team/convene.jinx +670 -0
  38. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.npc +5 -11
  39. npcsh-1.1.23.data/data/npcsh/npc_team/crond.jinx +818 -0
  40. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/delegate.jinx +1 -1
  41. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/edit_file.jinx +1 -1
  42. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic.npc +4 -6
  43. npcsh-1.1.23.data/data/npcsh/npc_team/git-workflow.jinx +44 -0
  44. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.npc +4 -4
  45. npcsh-1.1.23.data/data/npcsh/npc_team/help.jinx +236 -0
  46. npcsh-1.1.23.data/data/npcsh/npc_team/init.jinx +532 -0
  47. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/jinxs.jinx +0 -1
  48. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.npc +4 -5
  49. npcsh-1.1.23.data/data/npcsh/npc_team/npcsh.ctx +34 -0
  50. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.jinx +76 -14
  51. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.npc +5 -9
  52. npcsh-1.1.23.data/data/npcsh/npc_team/roll.jinx +378 -0
  53. npcsh-1.1.23.data/data/npcsh/npc_team/serve.jinx +943 -0
  54. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sh.jinx +1 -1
  55. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.npc +13 -5
  56. npcsh-1.1.23.data/data/npcsh/npc_team/skill.jinx +59 -0
  57. npcsh-1.1.23.data/data/npcsh/npc_team/skills.jinx +621 -0
  58. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.jinx +504 -30
  59. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/METADATA +168 -7
  60. npcsh-1.1.23.dist-info/RECORD +216 -0
  61. npcsh/npc_team/jinxs/incognide/add_tab.jinx +0 -11
  62. npcsh/npc_team/jinxs/incognide/close_pane.jinx +0 -9
  63. npcsh/npc_team/jinxs/incognide/close_tab.jinx +0 -10
  64. npcsh/npc_team/jinxs/incognide/confirm.jinx +0 -10
  65. npcsh/npc_team/jinxs/incognide/focus_pane.jinx +0 -9
  66. npcsh/npc_team/jinxs/incognide/list_panes.jinx +0 -8
  67. npcsh/npc_team/jinxs/incognide/navigate.jinx +0 -10
  68. npcsh/npc_team/jinxs/incognide/notify.jinx +0 -10
  69. npcsh/npc_team/jinxs/incognide/open_pane.jinx +0 -13
  70. npcsh/npc_team/jinxs/incognide/read_pane.jinx +0 -9
  71. npcsh/npc_team/jinxs/incognide/run_terminal.jinx +0 -10
  72. npcsh/npc_team/jinxs/incognide/send_message.jinx +0 -10
  73. npcsh/npc_team/jinxs/incognide/split_pane.jinx +0 -12
  74. npcsh/npc_team/jinxs/incognide/switch_npc.jinx +0 -10
  75. npcsh/npc_team/jinxs/incognide/switch_tab.jinx +0 -10
  76. npcsh/npc_team/jinxs/incognide/write_file.jinx +0 -11
  77. npcsh/npc_team/jinxs/incognide/zen_mode.jinx +0 -9
  78. npcsh/npc_team/jinxs/lib/core/convene.jinx +0 -232
  79. npcsh-1.1.22.data/data/npcsh/npc_team/add_tab.jinx +0 -11
  80. npcsh-1.1.22.data/data/npcsh/npc_team/close_pane.jinx +0 -9
  81. npcsh-1.1.22.data/data/npcsh/npc_team/close_tab.jinx +0 -10
  82. npcsh-1.1.22.data/data/npcsh/npc_team/confirm.jinx +0 -10
  83. npcsh-1.1.22.data/data/npcsh/npc_team/convene.jinx +0 -232
  84. npcsh-1.1.22.data/data/npcsh/npc_team/focus_pane.jinx +0 -9
  85. npcsh-1.1.22.data/data/npcsh/npc_team/help.jinx +0 -52
  86. npcsh-1.1.22.data/data/npcsh/npc_team/init.jinx +0 -41
  87. npcsh-1.1.22.data/data/npcsh/npc_team/list_panes.jinx +0 -8
  88. npcsh-1.1.22.data/data/npcsh/npc_team/navigate.jinx +0 -10
  89. npcsh-1.1.22.data/data/npcsh/npc_team/notify.jinx +0 -10
  90. npcsh-1.1.22.data/data/npcsh/npc_team/npcsh.ctx +0 -18
  91. npcsh-1.1.22.data/data/npcsh/npc_team/open_pane.jinx +0 -13
  92. npcsh-1.1.22.data/data/npcsh/npc_team/read_pane.jinx +0 -9
  93. npcsh-1.1.22.data/data/npcsh/npc_team/roll.jinx +0 -65
  94. npcsh-1.1.22.data/data/npcsh/npc_team/run_terminal.jinx +0 -10
  95. npcsh-1.1.22.data/data/npcsh/npc_team/send_message.jinx +0 -10
  96. npcsh-1.1.22.data/data/npcsh/npc_team/serve.jinx +0 -26
  97. npcsh-1.1.22.data/data/npcsh/npc_team/split_pane.jinx +0 -12
  98. npcsh-1.1.22.data/data/npcsh/npc_team/switch_npc.jinx +0 -10
  99. npcsh-1.1.22.data/data/npcsh/npc_team/switch_tab.jinx +0 -10
  100. npcsh-1.1.22.data/data/npcsh/npc_team/write_file.jinx +0 -11
  101. npcsh-1.1.22.data/data/npcsh/npc_team/zen_mode.jinx +0 -9
  102. npcsh-1.1.22.dist-info/RECORD +0 -240
  103. /npcsh/npc_team/jinxs/{incognide → lib/utils}/incognide.jinx +0 -0
  104. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.jinx +0 -0
  105. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.png +0 -0
  106. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
  107. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
  108. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  109. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  110. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/build.jinx +0 -0
  111. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/chat.jinx +0 -0
  112. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/click.jinx +0 -0
  113. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  114. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  115. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compile.jinx +0 -0
  116. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compress.jinx +0 -0
  117. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.jinx +0 -0
  118. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.png +0 -0
  119. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca_example.png +0 -0
  120. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/db_search.jinx +0 -0
  121. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/file_search.jinx +0 -0
  122. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic4.png +0 -0
  123. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/git.jinx +0 -0
  124. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.jinx +0 -0
  125. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.png +0 -0
  126. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  127. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  128. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  129. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kg.jinx +0 -0
  130. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  131. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  132. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/memories.jinx +0 -0
  133. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/models.jinx +0 -0
  134. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  135. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/nql.jinx +0 -0
  136. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  137. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/ots.jinx +0 -0
  138. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/papers.jinx +0 -0
  139. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/paste.jinx +0 -0
  140. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.png +0 -0
  141. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  142. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/pti.jinx +0 -0
  143. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/python.jinx +0 -0
  144. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/reattach.jinx +0 -0
  145. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sample.jinx +0 -0
  146. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  147. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/set.jinx +0 -0
  148. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/setup.jinx +0 -0
  149. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/shh.jinx +0 -0
  150. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.png +0 -0
  151. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  152. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.jinx +0 -0
  153. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.png +0 -0
  154. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sql.jinx +0 -0
  155. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switch.jinx +0 -0
  156. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switches.jinx +0 -0
  157. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sync.jinx +0 -0
  158. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/team.jinx +0 -0
  159. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  160. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  161. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  162. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/usage.jinx +0 -0
  163. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  164. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
  165. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wait.jinx +0 -0
  166. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wander.jinx +0 -0
  167. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/web_search.jinx +0 -0
  168. {npcsh-1.1.22.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.png +0 -0
  169. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/WHEEL +0 -0
  170. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/entry_points.txt +0 -0
  171. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/licenses/LICENSE +0 -0
  172. {npcsh-1.1.22.dist-info → npcsh-1.1.23.dist-info}/top_level.txt +0 -0
@@ -7,6 +7,7 @@ inputs:
7
7
  - tts_model: kokoro
8
8
  - voice: af_heart
9
9
  - files: null
10
+ - show_setup: false
10
11
 
11
12
  steps:
12
13
  - name: yap_tui
@@ -23,17 +24,17 @@ steps:
23
24
  import wave
24
25
  import numpy as np
25
26
  from faster_whisper import WhisperModel
26
- from gtts import gTTS
27
27
  from npcpy.data.audio import (
28
28
  FORMAT, CHANNELS, RATE, CHUNK,
29
29
  transcribe_recording, convert_mp3_to_wav
30
30
  )
31
+ from npcpy.gen.audio_gen import text_to_speech, get_available_engines, get_available_voices
31
32
  AUDIO_AVAILABLE = True
32
33
  except ImportError:
33
34
  AUDIO_AVAILABLE = False
34
35
 
35
36
  from npcpy.llm_funcs import get_llm_response
36
- from npcpy.npc_sysenv import get_system_message, render_markdown
37
+ from npcpy.npc_sysenv import get_system_message, render_markdown, get_locally_available_models
37
38
  from npcpy.data.load import load_file_contents
38
39
  from npcpy.data.text import rag_search
39
40
 
@@ -42,7 +43,8 @@ steps:
42
43
  messages = context.get('messages', [])
43
44
  files = context.get('files')
44
45
  tts_model_name = context.get('tts_model', 'kokoro')
45
- voice_name = context.get('voice', 'af_heart')
46
+ voice_name = context.get('voice') or None
47
+ show_setup = context.get('show_setup', False)
46
48
 
47
49
  if isinstance(npc, str) and team:
48
50
  npc = team.get(npc) if hasattr(team, 'get') else None
@@ -61,6 +63,285 @@ steps:
61
63
  context['messages'] = messages
62
64
  exit()
63
65
 
66
+ # ================================================================
67
+ # Gather available options for setup/modal
68
+ # ================================================================
69
+ _all_engines = []
70
+ _engine_voices = {}
71
+ try:
72
+ _engines_info = get_available_engines()
73
+ for ename, einfo in _engines_info.items():
74
+ _all_engines.append(ename)
75
+ try:
76
+ _engine_voices[ename] = [v['id'] if isinstance(v, dict) else str(v) for v in get_available_voices(ename)]
77
+ except Exception:
78
+ _engine_voices[ename] = []
79
+ except Exception:
80
+ _all_engines = ['kokoro', 'qwen3', 'elevenlabs', 'openai', 'gemini', 'gtts']
81
+ _engine_voices = {e: [] for e in _all_engines}
82
+
83
+ if not _all_engines:
84
+ _all_engines = ['kokoro']
85
+
86
+ _all_models = []
87
+ _all_providers = []
88
+ try:
89
+ _local = get_locally_available_models(os.getcwd())
90
+ _seen_models = set()
91
+ _seen_providers = set()
92
+ for entry in _local:
93
+ m = entry.get('model', '') if isinstance(entry, dict) else str(entry)
94
+ p = entry.get('provider', '') if isinstance(entry, dict) else ''
95
+ if m and m not in _seen_models:
96
+ _all_models.append(m)
97
+ _seen_models.add(m)
98
+ if p and p not in _seen_providers:
99
+ _all_providers.append(p)
100
+ _seen_providers.add(p)
101
+ except Exception:
102
+ pass
103
+
104
+ if not _all_models:
105
+ _all_models = [model or 'gemma3:4b']
106
+ if not _all_providers:
107
+ _all_providers = [provider or 'ollama']
108
+
109
+ # Ensure current selections are in the lists
110
+ if tts_model_name not in _all_engines:
111
+ _all_engines.insert(0, tts_model_name)
112
+ if model and model not in _all_models:
113
+ _all_models.insert(0, model)
114
+ if provider and provider not in _all_providers:
115
+ _all_providers.insert(0, provider)
116
+
117
+ def _voices_for_engine(eng):
118
+ v = _engine_voices.get(eng, [])
119
+ if not v:
120
+ defaults = {'kokoro': ['af_heart'], 'qwen3': ['ryan'], 'elevenlabs': ['rachel'],
121
+ 'openai': ['alloy'], 'gemini': ['en-US-Standard-A'], 'gtts': ['en']}
122
+ v = defaults.get(eng, ['default'])
123
+ return v
124
+
125
+ # ================================================================
126
+ # Setup screen
127
+ # ================================================================
128
+ def run_setup():
129
+ nonlocal tts_model_name, voice_name, model, provider
130
+
131
+ TURQ = '\033[38;2;64;224;208m'
132
+ PURPLE = '\033[38;2;180;130;255m'
133
+ ORANGE = '\033[38;2;255;165;0m'
134
+ GREEN = '\033[32m'
135
+ DIM = '\033[90m'
136
+ BOLD = '\033[1m'
137
+ REV = '\033[7m'
138
+ RST = '\033[0m'
139
+
140
+ def _sz():
141
+ try:
142
+ s = os.get_terminal_size()
143
+ return s.columns, s.lines
144
+ except:
145
+ return 80, 24
146
+
147
+ # Setup state
148
+ fields = ['model', 'provider', 'engine', 'voice']
149
+ field_labels = {'model': 'LLM Model', 'provider': 'LLM Provider',
150
+ 'engine': 'TTS Engine', 'voice': 'Voice'}
151
+
152
+ model_idx = _all_models.index(model) if model in _all_models else 0
153
+ provider_idx = _all_providers.index(provider) if provider in _all_providers else 0
154
+ engine_idx = _all_engines.index(tts_model_name) if tts_model_name in _all_engines else 0
155
+ cur_voices = _voices_for_engine(_all_engines[engine_idx])
156
+ voice_idx = 0
157
+ if voice_name and voice_name in cur_voices:
158
+ voice_idx = cur_voices.index(voice_name)
159
+
160
+ field_options = {
161
+ 'model': (_all_models, model_idx),
162
+ 'provider': (_all_providers, provider_idx),
163
+ 'engine': (_all_engines, engine_idx),
164
+ 'voice': (cur_voices, voice_idx),
165
+ }
166
+
167
+ sel = 0 # 0-3 = fields, 4 = save default, 5 = dont show again
168
+ save_default = True
169
+ dont_show = False
170
+ total_rows = 6 # 4 fields + 2 checkboxes
171
+
172
+ def _get_val(f):
173
+ opts, idx = field_options[f]
174
+ return opts[idx] if opts else '?'
175
+
176
+ def _render_setup():
177
+ w, h = _sz()
178
+ box_w = min(50, w - 4)
179
+ box_h = 18
180
+ sx = max(1, (w - box_w) // 2)
181
+ sy = max(1, (h - box_h) // 2)
182
+
183
+ buf = ['\033[2J\033[H']
184
+
185
+ # Box border
186
+ buf.append(f'\033[{sy};{sx}H{PURPLE}{"─" * box_w}{RST}')
187
+ title = " YAP Voice Chat Setup "
188
+ tp = sx + (box_w - len(title)) // 2
189
+ buf.append(f'\033[{sy};{tp}H{BOLD}{PURPLE}{title}{RST}')
190
+
191
+ y = sy + 2
192
+ for i, f in enumerate(fields):
193
+ opts, idx = field_options[f]
194
+ val = opts[idx] if opts else '?'
195
+ label = field_labels[f]
196
+ lpad = sx + 2
197
+
198
+ if i == sel:
199
+ arrow_l = f'{TURQ}\u25c4{RST}'
200
+ arrow_r = f'{TURQ}\u25ba{RST}'
201
+ buf.append(f'\033[{y};{lpad}H{REV} {label}: {RST} {arrow_l} {BOLD}{val}{RST} {arrow_r}')
202
+ else:
203
+ buf.append(f'\033[{y};{lpad}H {DIM}{label}:{RST} {val}')
204
+ y += 2
205
+
206
+ # Separator
207
+ buf.append(f'\033[{y};{sx + 2}H{DIM}{"─" * (box_w - 4)}{RST}')
208
+ y += 1
209
+
210
+ # Checkboxes
211
+ ck_save = f'{GREEN}[x]{RST}' if save_default else '[ ]'
212
+ ck_dont = f'{GREEN}[x]{RST}' if dont_show else '[ ]'
213
+
214
+ if sel == 4:
215
+ buf.append(f'\033[{y};{sx + 2}H{REV} {ck_save} Save as default {RST}')
216
+ else:
217
+ buf.append(f'\033[{y};{sx + 2}H {ck_save} Save as default')
218
+ y += 1
219
+
220
+ if sel == 5:
221
+ buf.append(f'\033[{y};{sx + 2}H{REV} {ck_dont} Don\'t show again {RST}')
222
+ else:
223
+ buf.append(f'\033[{y};{sx + 2}H {ck_dont} Don\'t show again')
224
+ y += 2
225
+
226
+ # Hints
227
+ buf.append(f'\033[{y};{sx + 2}H{DIM}\u2191/\u2193:Navigate \u2190/\u2192:Change Space:Toggle Enter:Start Ctrl+Q:Quit{RST}')
228
+ y += 1
229
+ buf.append(f'\033[{y};{sx}H{PURPLE}{"─" * box_w}{RST}')
230
+
231
+ sys.stdout.write(''.join(buf))
232
+ sys.stdout.flush()
233
+
234
+ fd = sys.stdin.fileno()
235
+ old = termios.tcgetattr(fd)
236
+ try:
237
+ tty.setcbreak(fd)
238
+ sys.stdout.write('\033[?25l')
239
+ running = True
240
+ while running:
241
+ _render_setup()
242
+ if _sel.select([fd], [], [], 0.1)[0]:
243
+ c = os.read(fd, 1).decode('latin-1')
244
+ if c == '\x11' or c == '\x03': # Ctrl+Q / Ctrl+C
245
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
246
+ sys.stdout.write('\033[?25h\033[2J\033[H')
247
+ sys.stdout.flush()
248
+ context['output'] = "Setup cancelled."
249
+ context['messages'] = messages
250
+ exit()
251
+
252
+ elif c == '\x1b': # Escape sequence
253
+ if _sel.select([fd], [], [], 0.05)[0]:
254
+ c2 = os.read(fd, 1).decode('latin-1')
255
+ if c2 == '[':
256
+ c3 = os.read(fd, 1).decode('latin-1')
257
+ if c3 == 'A': # Up
258
+ sel = max(0, sel - 1)
259
+ elif c3 == 'B': # Down
260
+ sel = min(total_rows - 1, sel + 1)
261
+ elif c3 == 'D': # Left
262
+ if sel < 4:
263
+ f = fields[sel]
264
+ opts, idx = field_options[f]
265
+ if opts:
266
+ new_idx = (idx - 1) % len(opts)
267
+ field_options[f] = (opts, new_idx)
268
+ if f == 'engine':
269
+ nv = _voices_for_engine(opts[new_idx])
270
+ field_options['voice'] = (nv, 0)
271
+ elif c3 == 'C': # Right
272
+ if sel < 4:
273
+ f = fields[sel]
274
+ opts, idx = field_options[f]
275
+ if opts:
276
+ new_idx = (idx + 1) % len(opts)
277
+ field_options[f] = (opts, new_idx)
278
+ if f == 'engine':
279
+ nv = _voices_for_engine(opts[new_idx])
280
+ field_options['voice'] = (nv, 0)
281
+
282
+ elif c == 'k':
283
+ sel = max(0, sel - 1)
284
+ elif c == 'j':
285
+ sel = min(total_rows - 1, sel + 1)
286
+ elif c == 'h':
287
+ if sel < 4:
288
+ f = fields[sel]
289
+ opts, idx = field_options[f]
290
+ if opts:
291
+ new_idx = (idx - 1) % len(opts)
292
+ field_options[f] = (opts, new_idx)
293
+ if f == 'engine':
294
+ nv = _voices_for_engine(opts[new_idx])
295
+ field_options['voice'] = (nv, 0)
296
+ elif c == 'l':
297
+ if sel < 4:
298
+ f = fields[sel]
299
+ opts, idx = field_options[f]
300
+ if opts:
301
+ new_idx = (idx + 1) % len(opts)
302
+ field_options[f] = (opts, new_idx)
303
+ if f == 'engine':
304
+ nv = _voices_for_engine(opts[new_idx])
305
+ field_options['voice'] = (nv, 0)
306
+ elif c == ' ':
307
+ if sel == 4:
308
+ save_default = not save_default
309
+ elif sel == 5:
310
+ dont_show = not dont_show
311
+ elif c in ('\r', '\n'):
312
+ running = False
313
+
314
+ finally:
315
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
316
+ sys.stdout.write('\033[?25h\033[2J\033[H')
317
+ sys.stdout.flush()
318
+
319
+ # Apply selections
320
+ model = _get_val('model')
321
+ provider = _get_val('provider')
322
+ tts_model_name = _get_val('engine')
323
+ voice_name = _get_val('voice')
324
+
325
+ # Persist if requested
326
+ if save_default or dont_show:
327
+ from npcsh.config import set_npcsh_config_value
328
+ if save_default:
329
+ set_npcsh_config_value("NPCSH_TTS_ENGINE", tts_model_name)
330
+ set_npcsh_config_value("NPCSH_TTS_VOICE", voice_name)
331
+ set_npcsh_config_value("NPCSH_CHAT_MODEL", model)
332
+ set_npcsh_config_value("NPCSH_CHAT_PROVIDER", provider)
333
+ if dont_show:
334
+ set_npcsh_config_value("NPCSH_YAP_SETUP_DONE", "1")
335
+
336
+ if show_setup:
337
+ run_setup()
338
+
339
+ # Set default voice if still None
340
+ if not voice_name:
341
+ defaults = {'kokoro': 'af_heart', 'qwen3': 'ryan', 'elevenlabs': 'rachel',
342
+ 'openai': 'alloy', 'gemini': 'en-US-Standard-A', 'gtts': 'en'}
343
+ voice_name = defaults.get(tts_model_name, 'default')
344
+
64
345
  # ================================================================
65
346
  # Audio models
66
347
  # ================================================================
@@ -138,8 +419,28 @@ steps:
138
419
  edit_buf = ""
139
420
  edit_key = ""
140
421
 
422
+ # Modal state
423
+ modal_open = False
424
+ modal_sel = 0
425
+ modal_engine_idx = 0
426
+ modal_voice_idx = 0
427
+ modal_model_idx = 0
428
+ modal_provider_idx = 0
429
+ modal_save = False
430
+
141
431
  ui = UI()
142
432
 
433
+ # Initialize modal indices to current selections
434
+ if tts_model_name in _all_engines:
435
+ ui.modal_engine_idx = _all_engines.index(tts_model_name)
436
+ if model and model in _all_models:
437
+ ui.modal_model_idx = _all_models.index(model)
438
+ if provider and provider in _all_providers:
439
+ ui.modal_provider_idx = _all_providers.index(provider)
440
+ cur_v = _voices_for_engine(tts_model_name)
441
+ if voice_name in cur_v:
442
+ ui.modal_voice_idx = cur_v.index(voice_name)
443
+
143
444
  # ================================================================
144
445
  # Helpers
145
446
  # ================================================================
@@ -159,7 +460,7 @@ steps:
159
460
  REV = '\033[7m'
160
461
  RST = '\033[0m'
161
462
  RED = '\033[31m'
162
- SPINNERS = ['', '', '', '', '', '', '', '', '', '']
463
+ SPINNERS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
163
464
 
164
465
  def wrap_text(text, width):
165
466
  lines = []
@@ -191,20 +492,35 @@ steps:
191
492
  return
192
493
  try:
193
494
  ui.speaking = True
194
- tts = gTTS(text=text, lang='en')
195
- mp3_f = tempfile.NamedTemporaryFile(suffix='.mp3', delete=False)
196
- mp3_path = mp3_f.name
197
- mp3_f.close()
198
- tts.save(mp3_path)
199
- wav_path = mp3_path.replace('.mp3', '.wav')
200
- convert_mp3_to_wav(mp3_path, wav_path)
201
495
  import subprocess
496
+
497
+ audio_bytes = text_to_speech(text, engine=tts_model_name, voice=voice_name)
498
+
499
+ # Determine file format from engine
500
+ if tts_model_name in ('elevenlabs', 'gtts'):
501
+ suffix = '.mp3'
502
+ else:
503
+ suffix = '.wav'
504
+
505
+ tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
506
+ tmp_path = tmp.name
507
+ tmp.write(audio_bytes)
508
+ tmp.close()
509
+
510
+ # If mp3, convert to wav for playback
511
+ play_path = tmp_path
512
+ if suffix == '.mp3':
513
+ wav_path = tmp_path.replace('.mp3', '.wav')
514
+ convert_mp3_to_wav(tmp_path, wav_path)
515
+ play_path = wav_path
516
+
202
517
  if sys.platform == 'darwin':
203
- subprocess.run(['afplay', wav_path], check=True, timeout=30)
518
+ subprocess.run(['afplay', play_path], check=True, timeout=60)
204
519
  elif sys.platform == 'linux':
205
- subprocess.run(['aplay', wav_path], check=True, timeout=30,
520
+ subprocess.run(['aplay', play_path], check=True, timeout=60,
206
521
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
207
- for _p in [mp3_path, wav_path]:
522
+
523
+ for _p in set([tmp_path, play_path]):
208
524
  try: os.remove(_p)
209
525
  except: pass
210
526
  except Exception as e:
@@ -387,18 +703,18 @@ steps:
387
703
 
388
704
  mic = ''
389
705
  if ui.recording:
390
- mic = f'{RED} REC {ui.rec_seconds:.1f}s{RST}'
706
+ mic = f'{RED}\u25cf REC {ui.rec_seconds:.1f}s{RST}'
391
707
  elif ui.transcribing:
392
- mic = f'{ORANGE} transcribing...{RST}'
708
+ mic = f'{ORANGE}\u25cf transcribing...{RST}'
393
709
  elif ui.speaking:
394
- mic = f'{GREEN} speaking...{RST}'
710
+ mic = f'{GREEN}\u25cf speaking...{RST}'
395
711
  elif ui.thinking:
396
712
  sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
397
713
  mic = f'{ORANGE}{sp} thinking...{RST}'
398
714
  elif ui.listening:
399
- mic = f'{TURQ} listening{RST}'
715
+ mic = f'{TURQ}\u25cf listening{RST}'
400
716
 
401
- audio_st = '🎤' if ui.listening else ('🔇' if not AUDIO_AVAILABLE else '')
717
+ audio_st = '\U0001f3a4' if ui.listening else ('\U0001f507' if not AUDIO_AVAILABLE else '\u23f8')
402
718
  right = f'{npc_name} | {audio_st} | {model or "?"}@{provider or "?"}'
403
719
  pad = w - 12 - len(right) - 20
404
720
  header = f'{PURPLE}YAP{RST} {tabs}{" " * max(0, pad)}{mic} {DIM}{right}{RST}'
@@ -409,6 +725,10 @@ steps:
409
725
  elif ui.tab == 1:
410
726
  render_settings(buf, w, h)
411
727
 
728
+ # Draw modal on top if open
729
+ if ui.modal_open:
730
+ render_modal(buf, w, h)
731
+
412
732
  sys.stdout.write(''.join(buf))
413
733
  sys.stdout.flush()
414
734
 
@@ -437,18 +757,18 @@ steps:
437
757
  tw = w - 5
438
758
  wrapped = wrap_text(text, tw)
439
759
  for i, l in enumerate(wrapped):
440
- prefix = f' {TURQ} ' if i == 0 else ' '
760
+ prefix = f' {TURQ}\u2139 ' if i == 0 else ' '
441
761
  all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
442
762
  elif role == 'error':
443
763
  tw = w - 5
444
764
  wrapped = wrap_text(text, tw)
445
765
  for i, l in enumerate(wrapped):
446
- prefix = f' {RED} ' if i == 0 else ' '
766
+ prefix = f' {RED}\u2717 ' if i == 0 else ' '
447
767
  all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
448
768
 
449
769
  if ui.recording:
450
770
  secs = ui.rec_seconds
451
- all_lines.append(f' {RED}🎙 Recording... {secs:.1f}s{RST}')
771
+ all_lines.append(f' {RED}\U0001f399 Recording... {secs:.1f}s{RST}')
452
772
  elif ui.transcribing:
453
773
  sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
454
774
  all_lines.append(f' {ORANGE}{sp} Transcribing...{RST}')
@@ -456,7 +776,7 @@ steps:
456
776
  sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
457
777
  all_lines.append(f' {ORANGE}{sp} thinking...{RST}')
458
778
  elif ui.speaking:
459
- all_lines.append(f' {GREEN}🔊 Speaking...{RST}')
779
+ all_lines.append(f' {GREEN}\U0001f50a Speaking...{RST}')
460
780
 
461
781
  # Scrolling
462
782
  if ui.chat_scroll == -1:
@@ -473,7 +793,7 @@ steps:
473
793
 
474
794
  # Input area
475
795
  div_y = 2 + chat_h
476
- buf.append(f'\033[{div_y};1H\033[K{DIM}{"" * w}{RST}')
796
+ buf.append(f'\033[{div_y};1H\033[K{DIM}{"\u2500" * w}{RST}')
477
797
  input_y = div_y + 1
478
798
  visible = ui.input_buf[-(w-4):] if len(ui.input_buf) > w - 4 else ui.input_buf
479
799
  buf.append(f'\033[{input_y};1H\033[K {BOLD}>{RST} {visible}\033[?25h')
@@ -481,9 +801,9 @@ steps:
481
801
  # Status bar
482
802
  if AUDIO_AVAILABLE:
483
803
  ltog = 'Ctrl+L:Pause' if ui.listening else 'Ctrl+L:Listen'
484
- hints = f'Enter:Send {ltog} PgUp/PgDn:Scroll Tab:Settings Ctrl+Q:Quit'
804
+ hints = f'Enter:Send {ltog} Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
485
805
  else:
486
- hints = 'Enter:Send PgUp/PgDn:Scroll Tab:Settings Ctrl+Q:Quit'
806
+ hints = 'Enter:Send Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
487
807
  buf.append(f'\033[{h};1H\033[K{REV} {hints[:w-2].ljust(w-2)} {RST}')
488
808
 
489
809
  def render_settings(buf, w, h):
@@ -496,7 +816,7 @@ steps:
496
816
  ]
497
817
 
498
818
  buf.append(f'\033[3;3H{BOLD}Voice Settings{RST}')
499
- buf.append(f'\033[4;3H{DIM}{"" * (w - 6)}{RST}')
819
+ buf.append(f'\033[4;3H{DIM}{"\u2500" * (w - 6)}{RST}')
500
820
 
501
821
  y = 6
502
822
  for i, (key, label, val) in enumerate(settings):
@@ -508,6 +828,10 @@ steps:
508
828
  buf.append(f'\033[{y};3H {BOLD}{label}:{RST} {val}')
509
829
  y += 2
510
830
 
831
+ y += 1
832
+ buf.append(f'\033[{y};3H{DIM}TTS Engine: {tts_model_name} Voice: {voice_name or "default"}{RST}')
833
+ y += 1
834
+ buf.append(f'\033[{y};3H{DIM}LLM: {model or "?"}@{provider or "?"}{RST}')
511
835
  y += 1
512
836
  buf.append(f'\033[{y};3H{DIM}Audio: {"Available" if AUDIO_AVAILABLE else "Not available"}{RST}')
513
837
  y += 1
@@ -522,12 +846,158 @@ steps:
522
846
  if ui.editing:
523
847
  buf.append(f'\033[{h};1H\033[K{REV} Enter:Save Esc:Cancel {RST}')
524
848
  else:
525
- buf.append(f'\033[{h};1H\033[K{REV} j/k:Navigate Space:Toggle e:Edit Tab:Chat Ctrl+Q:Quit {RST}')
849
+ buf.append(f'\033[{h};1H\033[K{REV} j/k:Navigate Space:Toggle e:Edit Ctrl+S:Quick Switch Tab:Chat Ctrl+Q:Quit {RST}')
850
+
851
+ # ================================================================
852
+ # Modal rendering and handling
853
+ # ================================================================
854
+ def render_modal(buf, w, h):
855
+ box_w = min(48, w - 4)
856
+ box_h = 16
857
+ sx = max(1, (w - box_w) // 2)
858
+ sy = max(1, (h - box_h) // 2)
859
+
860
+ # Clear box area
861
+ for y in range(sy, sy + box_h):
862
+ buf.append(f'\033[{y};{sx}H{" " * box_w}')
863
+
864
+ # Border
865
+ buf.append(f'\033[{sy};{sx}H{PURPLE}\u250c{"\u2500" * (box_w - 2)}\u2510{RST}')
866
+ for y in range(sy + 1, sy + box_h - 1):
867
+ buf.append(f'\033[{y};{sx}H{PURPLE}\u2502{RST}{" " * (box_w - 2)}{PURPLE}\u2502{RST}')
868
+ buf.append(f'\033[{sy + box_h - 1};{sx}H{PURPLE}\u2514{"\u2500" * (box_w - 2)}\u2518{RST}')
869
+
870
+ # Title
871
+ title = " Quick Settings "
872
+ tp = sx + (box_w - len(title)) // 2
873
+ buf.append(f'\033[{sy};{tp}H{BOLD}{PURPLE}{title}{RST}')
874
+
875
+ lpad = sx + 3
876
+ y = sy + 2
877
+
878
+ modal_fields = [
879
+ ('LLM Model', _all_models, ui.modal_model_idx),
880
+ ('Provider', _all_providers, ui.modal_provider_idx),
881
+ ('TTS Engine', _all_engines, ui.modal_engine_idx),
882
+ ('Voice', _voices_for_engine(_all_engines[ui.modal_engine_idx]), ui.modal_voice_idx),
883
+ ]
884
+
885
+ for i, (label, opts, idx) in enumerate(modal_fields):
886
+ val = opts[idx] if idx < len(opts) else '?'
887
+ if i == ui.modal_sel:
888
+ al = f'{TURQ}\u25c4{RST}'
889
+ ar = f'{TURQ}\u25ba{RST}'
890
+ buf.append(f'\033[{y};{lpad}H{REV} {label}: {RST} {al} {BOLD}{val}{RST} {ar}')
891
+ else:
892
+ buf.append(f'\033[{y};{lpad}H {DIM}{label}:{RST} {val}')
893
+ y += 2
894
+
895
+ # Save checkbox
896
+ ck = f'{GREEN}[x]{RST}' if ui.modal_save else '[ ]'
897
+ if ui.modal_sel == 4:
898
+ buf.append(f'\033[{y};{lpad}H{REV} {ck} Save as default {RST}')
899
+ else:
900
+ buf.append(f'\033[{y};{lpad}H {ck} Save as default')
901
+ y += 2
902
+
903
+ # Hints
904
+ buf.append(f'\033[{y};{lpad}H{DIM}\u2191\u2193:Nav \u2190\u2192:Change Spc:Toggle Enter:Apply Esc:Cancel{RST}')
905
+
906
+ def open_modal():
907
+ ui.modal_open = True
908
+ ui.modal_sel = 0
909
+ ui.modal_save = False
910
+ # Sync indices to current values
911
+ if tts_model_name in _all_engines:
912
+ ui.modal_engine_idx = _all_engines.index(tts_model_name)
913
+ if model and model in _all_models:
914
+ ui.modal_model_idx = _all_models.index(model)
915
+ if provider and provider in _all_providers:
916
+ ui.modal_provider_idx = _all_providers.index(provider)
917
+ cur_v = _voices_for_engine(_all_engines[ui.modal_engine_idx])
918
+ if voice_name in cur_v:
919
+ ui.modal_voice_idx = cur_v.index(voice_name)
920
+ else:
921
+ ui.modal_voice_idx = 0
922
+
923
+ def apply_modal():
924
+ nonlocal tts_model_name, voice_name, model, provider
925
+ model = _all_models[ui.modal_model_idx] if ui.modal_model_idx < len(_all_models) else model
926
+ provider = _all_providers[ui.modal_provider_idx] if ui.modal_provider_idx < len(_all_providers) else provider
927
+ tts_model_name = _all_engines[ui.modal_engine_idx] if ui.modal_engine_idx < len(_all_engines) else tts_model_name
928
+ cur_v = _voices_for_engine(tts_model_name)
929
+ voice_name = cur_v[ui.modal_voice_idx] if ui.modal_voice_idx < len(cur_v) else voice_name
930
+
931
+ ui.chat_log.append(('info', f'Settings: {model}@{provider}, TTS: {tts_model_name}/{voice_name}'))
932
+
933
+ if ui.modal_save:
934
+ from npcsh.config import set_npcsh_config_value
935
+ set_npcsh_config_value("NPCSH_TTS_ENGINE", tts_model_name)
936
+ set_npcsh_config_value("NPCSH_TTS_VOICE", voice_name)
937
+ set_npcsh_config_value("NPCSH_CHAT_MODEL", model)
938
+ set_npcsh_config_value("NPCSH_CHAT_PROVIDER", provider)
939
+ ui.chat_log.append(('info', 'Saved as defaults.'))
940
+
941
+ ui.modal_open = False
942
+
943
+ def handle_modal_key(c, fd):
944
+ if c == '\x1b': # Esc
945
+ if _sel.select([fd], [], [], 0.05)[0]:
946
+ c2 = os.read(fd, 1).decode('latin-1')
947
+ if c2 == '[':
948
+ c3 = os.read(fd, 1).decode('latin-1')
949
+ if c3 == 'A': # Up
950
+ ui.modal_sel = max(0, ui.modal_sel - 1)
951
+ elif c3 == 'B': # Down
952
+ ui.modal_sel = min(4, ui.modal_sel + 1)
953
+ elif c3 == 'D': # Left
954
+ _modal_cycle(-1)
955
+ elif c3 == 'C': # Right
956
+ _modal_cycle(1)
957
+ return True
958
+ # bare Esc = close
959
+ ui.modal_open = False
960
+ return True
961
+
962
+ if c == 'k':
963
+ ui.modal_sel = max(0, ui.modal_sel - 1)
964
+ elif c == 'j':
965
+ ui.modal_sel = min(4, ui.modal_sel + 1)
966
+ elif c == 'h':
967
+ _modal_cycle(-1)
968
+ elif c == 'l':
969
+ _modal_cycle(1)
970
+ elif c == ' ':
971
+ if ui.modal_sel == 4:
972
+ ui.modal_save = not ui.modal_save
973
+ elif c in ('\r', '\n'):
974
+ apply_modal()
975
+ elif c == '\x11': # Ctrl+Q in modal just closes it
976
+ ui.modal_open = False
977
+ return True
978
+
979
+ def _modal_cycle(direction):
980
+ if ui.modal_sel == 0:
981
+ ui.modal_model_idx = (ui.modal_model_idx + direction) % len(_all_models)
982
+ elif ui.modal_sel == 1:
983
+ ui.modal_provider_idx = (ui.modal_provider_idx + direction) % len(_all_providers)
984
+ elif ui.modal_sel == 2:
985
+ ui.modal_engine_idx = (ui.modal_engine_idx + direction) % len(_all_engines)
986
+ # Reset voice index when engine changes
987
+ ui.modal_voice_idx = 0
988
+ elif ui.modal_sel == 3:
989
+ cur_v = _voices_for_engine(_all_engines[ui.modal_engine_idx])
990
+ if cur_v:
991
+ ui.modal_voice_idx = (ui.modal_voice_idx + direction) % len(cur_v)
526
992
 
527
993
  # ================================================================
528
994
  # Input handling
529
995
  # ================================================================
530
996
  def handle_key(c, fd):
997
+ # Modal intercepts all keys
998
+ if ui.modal_open:
999
+ return handle_modal_key(c, fd)
1000
+
531
1001
  if c == '\t':
532
1002
  if not ui.editing:
533
1003
  ui.tab = (ui.tab + 1) % 2
@@ -536,6 +1006,9 @@ steps:
536
1006
  return False
537
1007
  if c == '\x03': # Ctrl+C
538
1008
  return True
1009
+ if c == '\x13': # Ctrl+S = open settings modal
1010
+ open_modal()
1011
+ return True
539
1012
 
540
1013
  # Escape sequences
541
1014
  if c == '\x1b':
@@ -676,11 +1149,12 @@ steps:
676
1149
  # Welcome
677
1150
  # ================================================================
678
1151
  ui.chat_log.append(('info', f'YAP voice chat. NPC: {npc_name}.'))
1152
+ ui.chat_log.append(('info', f'TTS: {tts_model_name}/{voice_name} LLM: {model or "?"}@{provider or "?"}'))
679
1153
  if AUDIO_AVAILABLE:
680
1154
  ui.chat_log.append(('info', 'Listening for speech. Just start talking, or type text.'))
681
- ui.chat_log.append(('info', 'Ctrl+L to pause/resume listening.'))
1155
+ ui.chat_log.append(('info', 'Ctrl+L to pause/resume listening. Ctrl+S to change settings.'))
682
1156
  else:
683
- ui.chat_log.append(('info', 'Audio not available. Text mode only.'))
1157
+ ui.chat_log.append(('info', 'Audio not available. Text mode only. Ctrl+S to change settings.'))
684
1158
  if loaded_chunks:
685
1159
  ui.chat_log.append(('info', f'{len(loaded_chunks)} files loaded for context.'))
686
1160