npcsh 1.1.21__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 (188) hide show
  1. npcsh/_state.py +282 -125
  2. npcsh/benchmark/npcsh_agent.py +77 -232
  3. npcsh/benchmark/templates/install-npcsh.sh.j2 +12 -4
  4. npcsh/config.py +5 -2
  5. npcsh/mcp_server.py +9 -1
  6. npcsh/npc_team/alicanto.npc +8 -6
  7. npcsh/npc_team/corca.npc +5 -12
  8. npcsh/npc_team/frederic.npc +6 -9
  9. npcsh/npc_team/guac.npc +4 -4
  10. npcsh/npc_team/jinxs/lib/core/delegate.jinx +1 -1
  11. npcsh/npc_team/jinxs/lib/core/edit_file.jinx +84 -62
  12. npcsh/npc_team/jinxs/lib/core/sh.jinx +1 -1
  13. npcsh/npc_team/jinxs/lib/core/skill.jinx +59 -0
  14. npcsh/npc_team/jinxs/lib/utils/help.jinx +194 -10
  15. npcsh/npc_team/jinxs/lib/utils/init.jinx +528 -37
  16. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +0 -1
  17. npcsh/npc_team/jinxs/lib/utils/serve.jinx +938 -21
  18. npcsh/npc_team/jinxs/modes/alicanto.jinx +102 -41
  19. npcsh/npc_team/jinxs/modes/build.jinx +378 -0
  20. npcsh-1.1.21.data/data/npcsh/npc_team/config_tui.jinx → npcsh/npc_team/jinxs/modes/config.jinx +1 -1
  21. npcsh/npc_team/jinxs/modes/convene.jinx +670 -0
  22. npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
  23. npcsh/npc_team/jinxs/modes/crond.jinx +818 -0
  24. npcsh/npc_team/jinxs/modes/kg.jinx +69 -2
  25. npcsh/npc_team/jinxs/modes/plonk.jinx +86 -15
  26. npcsh/npc_team/jinxs/modes/roll.jinx +368 -55
  27. npcsh/npc_team/jinxs/modes/skills.jinx +621 -0
  28. npcsh/npc_team/jinxs/modes/yap.jinx +1092 -177
  29. npcsh/npc_team/jinxs/skills/code-review/SKILL.md +45 -0
  30. npcsh/npc_team/jinxs/skills/debugging/SKILL.md +44 -0
  31. npcsh/npc_team/jinxs/skills/git-workflow.jinx +44 -0
  32. npcsh/npc_team/kadiefa.npc +6 -6
  33. npcsh/npc_team/npcsh.ctx +16 -0
  34. npcsh/npc_team/plonk.npc +5 -9
  35. npcsh/npc_team/sibiji.npc +15 -7
  36. npcsh/npcsh.py +1 -0
  37. npcsh/routes.py +0 -4
  38. npcsh/yap.py +22 -4
  39. npcsh-1.1.23.data/data/npcsh/npc_team/SKILL.md +44 -0
  40. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.jinx +102 -41
  41. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.npc +8 -6
  42. npcsh-1.1.23.data/data/npcsh/npc_team/build.jinx +378 -0
  43. npcsh/npc_team/jinxs/modes/config_tui.jinx → npcsh-1.1.23.data/data/npcsh/npc_team/config.jinx +1 -1
  44. npcsh-1.1.23.data/data/npcsh/npc_team/convene.jinx +670 -0
  45. npcsh-1.1.23.data/data/npcsh/npc_team/corca.jinx +820 -0
  46. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.npc +5 -12
  47. npcsh-1.1.23.data/data/npcsh/npc_team/crond.jinx +818 -0
  48. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/delegate.jinx +1 -1
  49. npcsh-1.1.23.data/data/npcsh/npc_team/edit_file.jinx +119 -0
  50. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic.npc +6 -9
  51. npcsh-1.1.23.data/data/npcsh/npc_team/git-workflow.jinx +44 -0
  52. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.npc +4 -4
  53. npcsh-1.1.23.data/data/npcsh/npc_team/help.jinx +236 -0
  54. npcsh-1.1.23.data/data/npcsh/npc_team/init.jinx +532 -0
  55. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/jinxs.jinx +0 -1
  56. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.npc +6 -6
  57. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kg.jinx +69 -2
  58. npcsh-1.1.23.data/data/npcsh/npc_team/npcsh.ctx +34 -0
  59. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.jinx +86 -15
  60. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.npc +5 -9
  61. npcsh-1.1.23.data/data/npcsh/npc_team/roll.jinx +378 -0
  62. npcsh-1.1.23.data/data/npcsh/npc_team/serve.jinx +943 -0
  63. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sh.jinx +1 -1
  64. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.npc +15 -7
  65. npcsh-1.1.23.data/data/npcsh/npc_team/skill.jinx +59 -0
  66. npcsh-1.1.23.data/data/npcsh/npc_team/skills.jinx +621 -0
  67. npcsh-1.1.23.data/data/npcsh/npc_team/yap.jinx +1190 -0
  68. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/METADATA +404 -278
  69. npcsh-1.1.23.dist-info/RECORD +216 -0
  70. npcsh/npc_team/jinxs/incognide/add_tab.jinx +0 -11
  71. npcsh/npc_team/jinxs/incognide/close_pane.jinx +0 -9
  72. npcsh/npc_team/jinxs/incognide/close_tab.jinx +0 -10
  73. npcsh/npc_team/jinxs/incognide/confirm.jinx +0 -10
  74. npcsh/npc_team/jinxs/incognide/focus_pane.jinx +0 -9
  75. npcsh/npc_team/jinxs/incognide/list_panes.jinx +0 -8
  76. npcsh/npc_team/jinxs/incognide/navigate.jinx +0 -10
  77. npcsh/npc_team/jinxs/incognide/notify.jinx +0 -10
  78. npcsh/npc_team/jinxs/incognide/open_pane.jinx +0 -13
  79. npcsh/npc_team/jinxs/incognide/read_pane.jinx +0 -9
  80. npcsh/npc_team/jinxs/incognide/run_terminal.jinx +0 -10
  81. npcsh/npc_team/jinxs/incognide/send_message.jinx +0 -10
  82. npcsh/npc_team/jinxs/incognide/split_pane.jinx +0 -12
  83. npcsh/npc_team/jinxs/incognide/switch_npc.jinx +0 -10
  84. npcsh/npc_team/jinxs/incognide/switch_tab.jinx +0 -10
  85. npcsh/npc_team/jinxs/incognide/write_file.jinx +0 -11
  86. npcsh/npc_team/jinxs/incognide/zen_mode.jinx +0 -9
  87. npcsh/npc_team/jinxs/lib/core/convene.jinx +0 -232
  88. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +0 -429
  89. npcsh/npc_team/jinxs/lib/core/search.jinx +0 -54
  90. npcsh/npc_team/jinxs/lib/utils/build.jinx +0 -65
  91. npcsh-1.1.21.data/data/npcsh/npc_team/add_tab.jinx +0 -11
  92. npcsh-1.1.21.data/data/npcsh/npc_team/build.jinx +0 -65
  93. npcsh-1.1.21.data/data/npcsh/npc_team/close_pane.jinx +0 -9
  94. npcsh-1.1.21.data/data/npcsh/npc_team/close_tab.jinx +0 -10
  95. npcsh-1.1.21.data/data/npcsh/npc_team/confirm.jinx +0 -10
  96. npcsh-1.1.21.data/data/npcsh/npc_team/convene.jinx +0 -232
  97. npcsh-1.1.21.data/data/npcsh/npc_team/corca.jinx +0 -430
  98. npcsh-1.1.21.data/data/npcsh/npc_team/edit_file.jinx +0 -97
  99. npcsh-1.1.21.data/data/npcsh/npc_team/focus_pane.jinx +0 -9
  100. npcsh-1.1.21.data/data/npcsh/npc_team/help.jinx +0 -52
  101. npcsh-1.1.21.data/data/npcsh/npc_team/init.jinx +0 -41
  102. npcsh-1.1.21.data/data/npcsh/npc_team/kg_search.jinx +0 -429
  103. npcsh-1.1.21.data/data/npcsh/npc_team/list_panes.jinx +0 -8
  104. npcsh-1.1.21.data/data/npcsh/npc_team/navigate.jinx +0 -10
  105. npcsh-1.1.21.data/data/npcsh/npc_team/notify.jinx +0 -10
  106. npcsh-1.1.21.data/data/npcsh/npc_team/npcsh.ctx +0 -18
  107. npcsh-1.1.21.data/data/npcsh/npc_team/open_pane.jinx +0 -13
  108. npcsh-1.1.21.data/data/npcsh/npc_team/read_pane.jinx +0 -9
  109. npcsh-1.1.21.data/data/npcsh/npc_team/roll.jinx +0 -65
  110. npcsh-1.1.21.data/data/npcsh/npc_team/run_terminal.jinx +0 -10
  111. npcsh-1.1.21.data/data/npcsh/npc_team/search.jinx +0 -54
  112. npcsh-1.1.21.data/data/npcsh/npc_team/send_message.jinx +0 -10
  113. npcsh-1.1.21.data/data/npcsh/npc_team/serve.jinx +0 -26
  114. npcsh-1.1.21.data/data/npcsh/npc_team/split_pane.jinx +0 -12
  115. npcsh-1.1.21.data/data/npcsh/npc_team/switch_npc.jinx +0 -10
  116. npcsh-1.1.21.data/data/npcsh/npc_team/switch_tab.jinx +0 -10
  117. npcsh-1.1.21.data/data/npcsh/npc_team/write_file.jinx +0 -11
  118. npcsh-1.1.21.data/data/npcsh/npc_team/yap.jinx +0 -275
  119. npcsh-1.1.21.data/data/npcsh/npc_team/zen_mode.jinx +0 -9
  120. npcsh-1.1.21.dist-info/RECORD +0 -243
  121. /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
  122. /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
  123. /npcsh/npc_team/jinxs/{incognide → lib/utils}/incognide.jinx +0 -0
  124. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/alicanto.png +0 -0
  125. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/arxiv.jinx +0 -0
  126. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/benchmark.jinx +0 -0
  127. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  128. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  129. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/chat.jinx +0 -0
  130. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/click.jinx +0 -0
  131. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  132. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  133. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compile.jinx +0 -0
  134. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/compress.jinx +0 -0
  135. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca.png +0 -0
  136. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/corca_example.png +0 -0
  137. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/db_search.jinx +0 -0
  138. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/file_search.jinx +0 -0
  139. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/frederic4.png +0 -0
  140. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/git.jinx +0 -0
  141. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.jinx +0 -0
  142. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/guac.png +0 -0
  143. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  144. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  145. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  146. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  147. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  148. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/memories.jinx +0 -0
  149. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/models.jinx +0 -0
  150. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  151. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/nql.jinx +0 -0
  152. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  153. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/ots.jinx +0 -0
  154. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/papers.jinx +0 -0
  155. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/paste.jinx +0 -0
  156. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonk.png +0 -0
  157. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  158. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/pti.jinx +0 -0
  159. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/python.jinx +0 -0
  160. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/reattach.jinx +0 -0
  161. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sample.jinx +0 -0
  162. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  163. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/set.jinx +0 -0
  164. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/setup.jinx +0 -0
  165. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/shh.jinx +0 -0
  166. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sibiji.png +0 -0
  167. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  168. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.jinx +0 -0
  169. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/spool.png +0 -0
  170. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sql.jinx +0 -0
  171. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switch.jinx +0 -0
  172. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/switches.jinx +0 -0
  173. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/sync.jinx +0 -0
  174. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/team.jinx +0 -0
  175. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  176. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  177. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  178. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/usage.jinx +0 -0
  179. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  180. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/vixynt.jinx +0 -0
  181. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wait.jinx +0 -0
  182. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/wander.jinx +0 -0
  183. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/web_search.jinx +0 -0
  184. {npcsh-1.1.21.data → npcsh-1.1.23.data}/data/npcsh/npc_team/yap.png +0 -0
  185. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/WHEEL +0 -0
  186. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/entry_points.txt +0 -0
  187. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/licenses/LICENSE +0 -0
  188. {npcsh-1.1.21.dist-info → npcsh-1.1.23.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1190 @@
1
+ jinx_name: yap
2
+ description: Voice chat TUI - speech-to-text input, text-to-speech output
3
+ interactive: true
4
+ inputs:
5
+ - model: null
6
+ - provider: null
7
+ - tts_model: kokoro
8
+ - voice: af_heart
9
+ - files: null
10
+ - show_setup: false
11
+
12
+ steps:
13
+ - name: yap_tui
14
+ engine: python
15
+ code: |
16
+ import os, sys, tty, termios, time, tempfile, threading, queue
17
+ import select as _sel
18
+ from termcolor import colored
19
+
20
+ # Audio imports
21
+ try:
22
+ import torch
23
+ import pyaudio
24
+ import wave
25
+ import numpy as np
26
+ from faster_whisper import WhisperModel
27
+ from npcpy.data.audio import (
28
+ FORMAT, CHANNELS, RATE, CHUNK,
29
+ transcribe_recording, convert_mp3_to_wav
30
+ )
31
+ from npcpy.gen.audio_gen import text_to_speech, get_available_engines, get_available_voices
32
+ AUDIO_AVAILABLE = True
33
+ except ImportError:
34
+ AUDIO_AVAILABLE = False
35
+
36
+ from npcpy.llm_funcs import get_llm_response
37
+ from npcpy.npc_sysenv import get_system_message, render_markdown, get_locally_available_models
38
+ from npcpy.data.load import load_file_contents
39
+ from npcpy.data.text import rag_search
40
+
41
+ npc = context.get('npc')
42
+ team = context.get('team')
43
+ messages = context.get('messages', [])
44
+ files = context.get('files')
45
+ tts_model_name = context.get('tts_model', 'kokoro')
46
+ voice_name = context.get('voice') or None
47
+ show_setup = context.get('show_setup', False)
48
+
49
+ if isinstance(npc, str) and team:
50
+ npc = team.get(npc) if hasattr(team, 'get') else None
51
+ elif isinstance(npc, str):
52
+ npc = None
53
+
54
+ model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
55
+ provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
56
+ npc_name = npc.name if npc else "yap"
57
+
58
+ # ================================================================
59
+ # Non-interactive fallback
60
+ # ================================================================
61
+ if not sys.stdin.isatty():
62
+ context['output'] = "Yap requires an interactive terminal."
63
+ context['messages'] = messages
64
+ exit()
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
+
345
+ # ================================================================
346
+ # Audio models
347
+ # ================================================================
348
+ vad_model = None
349
+ whisper_model = None
350
+
351
+ if AUDIO_AVAILABLE:
352
+ try:
353
+ vad_model, _ = torch.hub.load(
354
+ repo_or_dir="snakers4/silero-vad",
355
+ model="silero_vad",
356
+ force_reload=False,
357
+ onnx=False,
358
+ verbose=False
359
+ )
360
+ vad_model.to('cpu')
361
+ except Exception:
362
+ pass
363
+ try:
364
+ whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
365
+ except Exception:
366
+ AUDIO_AVAILABLE = False
367
+
368
+ # ================================================================
369
+ # File loading for RAG
370
+ # ================================================================
371
+ loaded_chunks = {}
372
+ if files:
373
+ if isinstance(files, str):
374
+ files = [f.strip() for f in files.split(',')]
375
+ for fp in files:
376
+ fp = os.path.expanduser(fp)
377
+ if os.path.exists(fp):
378
+ try:
379
+ loaded_chunks[fp] = load_file_contents(fp)
380
+ except Exception:
381
+ pass
382
+
383
+ # System message
384
+ sys_msg = get_system_message(npc) if npc else "You are a helpful assistant."
385
+ sys_msg += "\n\nProvide brief responses of 1-2 sentences unless asked for more detail. Keep responses clear and conversational for voice."
386
+ if not messages or messages[0].get("role") != "system":
387
+ messages.insert(0, {"role": "system", "content": sys_msg})
388
+
389
+ # ================================================================
390
+ # State
391
+ # ================================================================
392
+ class UI:
393
+ tab = 0 # 0=chat, 1=settings
394
+ TAB_NAMES = ['Chat', 'Settings']
395
+
396
+ # chat
397
+ chat_log = [] # [(role, text)]
398
+ chat_scroll = -1
399
+ input_buf = ""
400
+ thinking = False
401
+ spinner_frame = 0
402
+ recording = False
403
+ rec_seconds = 0.0
404
+ transcribing = False
405
+ speaking = False
406
+
407
+ # VAD listening
408
+ listening = AUDIO_AVAILABLE # auto-listen by default
409
+ listen_stop = False # signal to stop listener thread
410
+
411
+ # settings
412
+ set_sel = 0
413
+ tts_enabled = AUDIO_AVAILABLE
414
+ auto_speak = True
415
+ vad_threshold = 0.4 # speech probability threshold
416
+ silence_timeout = 1.5 # seconds of silence before cut
417
+ min_speech = 0.3 # minimum speech duration to process
418
+ editing = False
419
+ edit_buf = ""
420
+ edit_key = ""
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
+
431
+ ui = UI()
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
+
444
+ # ================================================================
445
+ # Helpers
446
+ # ================================================================
447
+ def sz():
448
+ try:
449
+ s = os.get_terminal_size()
450
+ return s.columns, s.lines
451
+ except:
452
+ return 80, 24
453
+
454
+ TURQ = '\033[38;2;64;224;208m'
455
+ PURPLE = '\033[38;2;180;130;255m'
456
+ ORANGE = '\033[38;2;255;165;0m'
457
+ GREEN = '\033[32m'
458
+ DIM = '\033[90m'
459
+ BOLD = '\033[1m'
460
+ REV = '\033[7m'
461
+ RST = '\033[0m'
462
+ RED = '\033[31m'
463
+ SPINNERS = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
464
+
465
+ def wrap_text(text, width):
466
+ lines = []
467
+ for line in text.split('\n'):
468
+ while len(line) > width:
469
+ lines.append(line[:width])
470
+ line = line[width:]
471
+ lines.append(line)
472
+ return lines
473
+
474
+ # ================================================================
475
+ # Audio functions
476
+ # ================================================================
477
+ def transcribe_audio(audio_path):
478
+ if not whisper_model or not audio_path:
479
+ return ""
480
+ try:
481
+ segments, _ = whisper_model.transcribe(audio_path, beam_size=5)
482
+ text = " ".join([seg.text for seg in segments]).strip()
483
+ try: os.remove(audio_path)
484
+ except: pass
485
+ return text
486
+ except Exception as e:
487
+ ui.chat_log.append(('error', f'Transcribe error: {e}'))
488
+ return ""
489
+
490
+ def speak_text(text):
491
+ if not AUDIO_AVAILABLE or not ui.tts_enabled:
492
+ return
493
+ try:
494
+ ui.speaking = True
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
+
517
+ if sys.platform == 'darwin':
518
+ subprocess.run(['afplay', play_path], check=True, timeout=60)
519
+ elif sys.platform == 'linux':
520
+ subprocess.run(['aplay', play_path], check=True, timeout=60,
521
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
522
+
523
+ for _p in set([tmp_path, play_path]):
524
+ try: os.remove(_p)
525
+ except: pass
526
+ except Exception as e:
527
+ ui.chat_log.append(('error', f'TTS error: {e}'))
528
+ finally:
529
+ ui.speaking = False
530
+
531
+ def save_frames_to_wav(frames, sample_width):
532
+ f = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
533
+ path = f.name
534
+ f.close()
535
+ wf = wave.open(path, 'wb')
536
+ wf.setnchannels(CHANNELS)
537
+ wf.setsampwidth(sample_width)
538
+ wf.setframerate(RATE)
539
+ wf.writeframes(b''.join(frames))
540
+ wf.close()
541
+ return path
542
+
543
+ # ================================================================
544
+ # VAD continuous listener
545
+ # ================================================================
546
+ def vad_listener_loop():
547
+ """Background thread: continuously monitors mic, detects speech via
548
+ VAD, records until silence, then transcribes and sends."""
549
+ try:
550
+ p = pyaudio.PyAudio()
551
+ sw = p.get_sample_size(FORMAT)
552
+ stream = p.open(format=FORMAT, channels=CHANNELS, rate=RATE,
553
+ input=True, frames_per_buffer=CHUNK)
554
+ except Exception as e:
555
+ ui.chat_log.append(('error', f'Mic open failed: {e}'))
556
+ ui.listening = False
557
+ return
558
+
559
+ chunk_dur = CHUNK / RATE # duration of one chunk in seconds
560
+
561
+ while not ui.listen_stop:
562
+ # Skip if busy
563
+ if ui.thinking or ui.speaking or ui.transcribing:
564
+ time.sleep(0.1)
565
+ continue
566
+ if not ui.listening:
567
+ time.sleep(0.1)
568
+ continue
569
+
570
+ # Read a chunk and run VAD
571
+ try:
572
+ data = stream.read(CHUNK, exception_on_overflow=False)
573
+ except Exception:
574
+ time.sleep(0.05)
575
+ continue
576
+
577
+ audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
578
+ if len(audio_np) != CHUNK:
579
+ continue
580
+
581
+ try:
582
+ tensor = torch.from_numpy(audio_np)
583
+ prob = vad_model(tensor, RATE).item()
584
+ except Exception:
585
+ continue
586
+
587
+ if prob < ui.vad_threshold:
588
+ continue
589
+
590
+ # Speech detected — start collecting frames
591
+ ui.recording = True
592
+ ui.rec_seconds = 0.0
593
+ ui.chat_scroll = -1
594
+ speech_frames = [data]
595
+ speech_dur = chunk_dur
596
+ silence_dur = 0.0
597
+
598
+ while not ui.listen_stop:
599
+ try:
600
+ data = stream.read(CHUNK, exception_on_overflow=False)
601
+ except Exception:
602
+ break
603
+
604
+ speech_frames.append(data)
605
+ speech_dur += chunk_dur
606
+ ui.rec_seconds = speech_dur
607
+
608
+ audio_np = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
609
+ try:
610
+ tensor = torch.from_numpy(audio_np)
611
+ prob = vad_model(tensor, RATE).item()
612
+ except Exception:
613
+ prob = 0.0
614
+
615
+ if prob < ui.vad_threshold:
616
+ silence_dur += chunk_dur
617
+ else:
618
+ silence_dur = 0.0
619
+
620
+ if silence_dur >= ui.silence_timeout:
621
+ break
622
+
623
+ # Safety: max 60 seconds
624
+ if speech_dur > 60.0:
625
+ break
626
+
627
+ ui.recording = False
628
+
629
+ # Only process if enough speech
630
+ if speech_dur - silence_dur < ui.min_speech:
631
+ continue
632
+
633
+ # Transcribe
634
+ ui.transcribing = True
635
+ audio_path = save_frames_to_wav(speech_frames, sw)
636
+ text = transcribe_audio(audio_path)
637
+ ui.transcribing = False
638
+
639
+ if text and text.strip():
640
+ ui.chat_log.append(('info', f'Heard: "{text}"'))
641
+ ui.chat_scroll = -1
642
+ send_message(text)
643
+
644
+ # Cleanup
645
+ try:
646
+ stream.stop_stream()
647
+ stream.close()
648
+ p.terminate()
649
+ except Exception:
650
+ pass
651
+
652
+ # ================================================================
653
+ # Chat send
654
+ # ================================================================
655
+ def send_message(text):
656
+ ui.chat_log.append(('user', text))
657
+ ui.thinking = True
658
+ ui.chat_scroll = -1
659
+
660
+ def worker():
661
+ try:
662
+ current_prompt = text
663
+ if loaded_chunks:
664
+ ctx_content = ""
665
+ for fn, chunks in loaded_chunks.items():
666
+ full = "\n".join(chunks)
667
+ ret = rag_search(text, full, similarity_threshold=0.3)
668
+ if ret:
669
+ ctx_content += f"\n{ret}\n"
670
+ if ctx_content:
671
+ current_prompt += f"\n\nContext:{ctx_content}"
672
+
673
+ resp = get_llm_response(
674
+ current_prompt, model=model, provider=provider,
675
+ messages=messages, stream=False, npc=npc
676
+ )
677
+ messages[:] = resp.get('messages', messages)
678
+ response_text = str(resp.get('response', ''))
679
+ if response_text:
680
+ ui.chat_log.append(('assistant', response_text))
681
+ if ui.auto_speak and ui.tts_enabled:
682
+ speak_text(response_text)
683
+ except Exception as e:
684
+ ui.chat_log.append(('error', str(e)))
685
+ ui.thinking = False
686
+
687
+ threading.Thread(target=worker, daemon=True).start()
688
+
689
+ # ================================================================
690
+ # Rendering
691
+ # ================================================================
692
+ def render():
693
+ w, h = sz()
694
+ buf = ['\033[H']
695
+
696
+ # Tab bar
697
+ tabs = ''
698
+ for i, name in enumerate(ui.TAB_NAMES):
699
+ if i == ui.tab:
700
+ tabs += f' {REV}{BOLD} {name} {RST} '
701
+ else:
702
+ tabs += f' {DIM} {name} {RST} '
703
+
704
+ mic = ''
705
+ if ui.recording:
706
+ mic = f'{RED}\u25cf REC {ui.rec_seconds:.1f}s{RST}'
707
+ elif ui.transcribing:
708
+ mic = f'{ORANGE}\u25cf transcribing...{RST}'
709
+ elif ui.speaking:
710
+ mic = f'{GREEN}\u25cf speaking...{RST}'
711
+ elif ui.thinking:
712
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
713
+ mic = f'{ORANGE}{sp} thinking...{RST}'
714
+ elif ui.listening:
715
+ mic = f'{TURQ}\u25cf listening{RST}'
716
+
717
+ audio_st = '\U0001f3a4' if ui.listening else ('\U0001f507' if not AUDIO_AVAILABLE else '\u23f8')
718
+ right = f'{npc_name} | {audio_st} | {model or "?"}@{provider or "?"}'
719
+ pad = w - 12 - len(right) - 20
720
+ header = f'{PURPLE}YAP{RST} {tabs}{" " * max(0, pad)}{mic} {DIM}{right}{RST}'
721
+ buf.append(f'\033[1;1H{REV} {header[:w-2].ljust(w-2)} {RST}')
722
+
723
+ if ui.tab == 0:
724
+ render_chat(buf, w, h)
725
+ elif ui.tab == 1:
726
+ render_settings(buf, w, h)
727
+
728
+ # Draw modal on top if open
729
+ if ui.modal_open:
730
+ render_modal(buf, w, h)
731
+
732
+ sys.stdout.write(''.join(buf))
733
+ sys.stdout.flush()
734
+
735
+ def render_chat(buf, w, h):
736
+ input_h = 3
737
+ chat_h = h - 2 - input_h
738
+
739
+ all_lines = []
740
+ _asst_pw = len(npc_name) + 2 # "name: "
741
+ _cont_pw = _asst_pw # continuation indent matches
742
+ for role, text in ui.chat_log:
743
+ if role == 'user':
744
+ tw = w - 6
745
+ wrapped = wrap_text(text, tw)
746
+ for i, l in enumerate(wrapped):
747
+ prefix = f'{BOLD}you:{RST} ' if i == 0 else ' '
748
+ all_lines.append(f'{prefix}{l}')
749
+ elif role == 'assistant':
750
+ tw = w - _asst_pw - 1
751
+ wrapped = wrap_text(text, tw)
752
+ pad = ' ' * _asst_pw
753
+ for i, l in enumerate(wrapped):
754
+ prefix = f'{PURPLE}{BOLD}{npc_name}:{RST} ' if i == 0 else pad
755
+ all_lines.append(f'{prefix}{l}')
756
+ elif role == 'info':
757
+ tw = w - 5
758
+ wrapped = wrap_text(text, tw)
759
+ for i, l in enumerate(wrapped):
760
+ prefix = f' {TURQ}\u2139 ' if i == 0 else ' '
761
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
762
+ elif role == 'error':
763
+ tw = w - 5
764
+ wrapped = wrap_text(text, tw)
765
+ for i, l in enumerate(wrapped):
766
+ prefix = f' {RED}\u2717 ' if i == 0 else ' '
767
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
768
+
769
+ if ui.recording:
770
+ secs = ui.rec_seconds
771
+ all_lines.append(f' {RED}\U0001f399 Recording... {secs:.1f}s{RST}')
772
+ elif ui.transcribing:
773
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
774
+ all_lines.append(f' {ORANGE}{sp} Transcribing...{RST}')
775
+ elif ui.thinking:
776
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
777
+ all_lines.append(f' {ORANGE}{sp} thinking...{RST}')
778
+ elif ui.speaking:
779
+ all_lines.append(f' {GREEN}\U0001f50a Speaking...{RST}')
780
+
781
+ # Scrolling
782
+ if ui.chat_scroll == -1:
783
+ scroll = max(0, len(all_lines) - chat_h)
784
+ else:
785
+ scroll = ui.chat_scroll
786
+
787
+ for i in range(chat_h):
788
+ y = 2 + i
789
+ li = scroll + i
790
+ buf.append(f'\033[{y};1H\033[K')
791
+ if li < len(all_lines):
792
+ buf.append(all_lines[li])
793
+
794
+ # Input area
795
+ div_y = 2 + chat_h
796
+ buf.append(f'\033[{div_y};1H\033[K{DIM}{"\u2500" * w}{RST}')
797
+ input_y = div_y + 1
798
+ visible = ui.input_buf[-(w-4):] if len(ui.input_buf) > w - 4 else ui.input_buf
799
+ buf.append(f'\033[{input_y};1H\033[K {BOLD}>{RST} {visible}\033[?25h')
800
+
801
+ # Status bar
802
+ if AUDIO_AVAILABLE:
803
+ ltog = 'Ctrl+L:Pause' if ui.listening else 'Ctrl+L:Listen'
804
+ hints = f'Enter:Send {ltog} Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
805
+ else:
806
+ hints = 'Enter:Send Ctrl+S:Settings Tab:Settings Ctrl+Q:Quit'
807
+ buf.append(f'\033[{h};1H\033[K{REV} {hints[:w-2].ljust(w-2)} {RST}')
808
+
809
+ def render_settings(buf, w, h):
810
+ settings = [
811
+ ('tts_enabled', 'TTS Enabled', 'On' if ui.tts_enabled else 'Off'),
812
+ ('auto_speak', 'Auto-Speak', 'On' if ui.auto_speak else 'Off'),
813
+ ('listening', 'Auto-Listen', 'On' if ui.listening else 'Off'),
814
+ ('silence_timeout', 'Silence Timeout', f'{ui.silence_timeout}s'),
815
+ ('vad_threshold', 'VAD Sensitivity', f'{ui.vad_threshold:.1f}'),
816
+ ]
817
+
818
+ buf.append(f'\033[3;3H{BOLD}Voice Settings{RST}')
819
+ buf.append(f'\033[4;3H{DIM}{"\u2500" * (w - 6)}{RST}')
820
+
821
+ y = 6
822
+ for i, (key, label, val) in enumerate(settings):
823
+ if ui.editing and ui.edit_key == key:
824
+ buf.append(f'\033[{y};3H{ORANGE}{label}:{RST} {REV} {ui.edit_buf}_ {RST}')
825
+ elif i == ui.set_sel:
826
+ buf.append(f'\033[{y};3H{REV} {label}: {val} {RST}')
827
+ else:
828
+ buf.append(f'\033[{y};3H {BOLD}{label}:{RST} {val}')
829
+ y += 2
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}')
835
+ y += 1
836
+ buf.append(f'\033[{y};3H{DIM}Audio: {"Available" if AUDIO_AVAILABLE else "Not available"}{RST}')
837
+ y += 1
838
+ if loaded_chunks:
839
+ buf.append(f'\033[{y};3H{DIM}Files loaded: {len(loaded_chunks)}{RST}')
840
+ y += 1
841
+ buf.append(f'\033[{y};3H{DIM}Whisper: {"Loaded" if whisper_model else "Not loaded"}{RST}')
842
+
843
+ for cy in range(y + 1, h - 1):
844
+ buf.append(f'\033[{cy};1H\033[K')
845
+
846
+ if ui.editing:
847
+ buf.append(f'\033[{h};1H\033[K{REV} Enter:Save Esc:Cancel {RST}')
848
+ else:
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)
992
+
993
+ # ================================================================
994
+ # Input handling
995
+ # ================================================================
996
+ def handle_key(c, fd):
997
+ # Modal intercepts all keys
998
+ if ui.modal_open:
999
+ return handle_modal_key(c, fd)
1000
+
1001
+ if c == '\t':
1002
+ if not ui.editing:
1003
+ ui.tab = (ui.tab + 1) % 2
1004
+ return True
1005
+ if c == '\x11': # Ctrl+Q
1006
+ return False
1007
+ if c == '\x03': # Ctrl+C
1008
+ return True
1009
+ if c == '\x13': # Ctrl+S = open settings modal
1010
+ open_modal()
1011
+ return True
1012
+
1013
+ # Escape sequences
1014
+ if c == '\x1b':
1015
+ if _sel.select([fd], [], [], 0.05)[0]:
1016
+ c2 = os.read(fd, 1).decode('latin-1')
1017
+ if c2 == '[':
1018
+ c3 = os.read(fd, 1).decode('latin-1')
1019
+ if c3 == 'A': # Up
1020
+ if ui.tab == 0: _chat_scroll_up()
1021
+ elif ui.tab == 1 and not ui.editing and ui.set_sel > 0: ui.set_sel -= 1
1022
+ elif c3 == 'B': # Down
1023
+ if ui.tab == 0: _chat_scroll_down()
1024
+ elif ui.tab == 1 and not ui.editing and ui.set_sel < 4: ui.set_sel += 1
1025
+ elif c3 == '5': # PgUp
1026
+ os.read(fd, 1)
1027
+ if ui.tab == 0: _chat_page_up()
1028
+ elif c3 == '6': # PgDn
1029
+ os.read(fd, 1)
1030
+ if ui.tab == 0: _chat_page_down()
1031
+ elif c2 == 'O':
1032
+ c3 = os.read(fd, 1).decode('latin-1')
1033
+ if c3 == 'P': ui.tab = 0 # F1
1034
+ elif c3 == 'Q': ui.tab = 1 # F2
1035
+ else:
1036
+ # bare Esc
1037
+ if ui.tab == 1 and ui.editing:
1038
+ ui.editing = False
1039
+ ui.edit_buf = ""
1040
+ else:
1041
+ if ui.tab == 1 and ui.editing:
1042
+ ui.editing = False
1043
+ ui.edit_buf = ""
1044
+ return True
1045
+
1046
+ if ui.tab == 0:
1047
+ return handle_chat(c, fd)
1048
+ elif ui.tab == 1:
1049
+ return handle_settings(c, fd)
1050
+ return True
1051
+
1052
+ def _chat_scroll_up():
1053
+ _, h = sz()
1054
+ chat_h = h - 5
1055
+ if ui.chat_scroll == -1:
1056
+ ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - 1)
1057
+ ui.chat_scroll = max(0, ui.chat_scroll - 1)
1058
+
1059
+ def _chat_scroll_down():
1060
+ ui.chat_scroll = -1 if ui.chat_scroll == -1 else ui.chat_scroll + 1
1061
+
1062
+ def _chat_page_up():
1063
+ _, h = sz()
1064
+ chat_h = h - 5
1065
+ if ui.chat_scroll == -1:
1066
+ ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - chat_h)
1067
+ else:
1068
+ ui.chat_scroll = max(0, ui.chat_scroll - chat_h)
1069
+
1070
+ def _chat_page_down():
1071
+ ui.chat_scroll = -1
1072
+
1073
+ def handle_chat(c, fd):
1074
+ # Ctrl+L = toggle listening
1075
+ if c == '\x0c': # Ctrl+L
1076
+ if AUDIO_AVAILABLE:
1077
+ ui.listening = not ui.listening
1078
+ st = 'on' if ui.listening else 'off'
1079
+ ui.chat_log.append(('info', f'Listening {st}.'))
1080
+ return True
1081
+
1082
+ if ui.recording or ui.transcribing:
1083
+ return True
1084
+
1085
+ if ui.thinking:
1086
+ return True
1087
+
1088
+ if c in ('\r', '\n'):
1089
+ text = ui.input_buf.strip()
1090
+ ui.input_buf = ""
1091
+ if text:
1092
+ send_message(text)
1093
+ return True
1094
+
1095
+ if c == '\x7f' or c == '\x08':
1096
+ ui.input_buf = ui.input_buf[:-1]
1097
+ return True
1098
+
1099
+ if c >= ' ' and c <= '~':
1100
+ ui.input_buf += c
1101
+ ui.chat_scroll = -1
1102
+ return True
1103
+
1104
+ return True
1105
+
1106
+ def handle_settings(c, fd):
1107
+ SETTINGS_KEYS = ['tts_enabled', 'auto_speak', 'listening', 'silence_timeout', 'vad_threshold']
1108
+
1109
+ if ui.editing:
1110
+ if c in ('\r', '\n'):
1111
+ val = ui.edit_buf.strip()
1112
+ if ui.edit_key == 'silence_timeout':
1113
+ try: ui.silence_timeout = max(0.3, min(10.0, float(val)))
1114
+ except: pass
1115
+ elif ui.edit_key == 'vad_threshold':
1116
+ try: ui.vad_threshold = max(0.1, min(0.9, float(val)))
1117
+ except: pass
1118
+ ui.editing = False
1119
+ ui.edit_buf = ""
1120
+ elif c == '\x7f' or c == '\x08':
1121
+ ui.edit_buf = ui.edit_buf[:-1]
1122
+ elif c >= ' ' and c <= '~':
1123
+ ui.edit_buf += c
1124
+ return True
1125
+
1126
+ if c == 'j' and ui.set_sel < len(SETTINGS_KEYS) - 1:
1127
+ ui.set_sel += 1
1128
+ elif c == 'k' and ui.set_sel > 0:
1129
+ ui.set_sel -= 1
1130
+ elif c == ' ':
1131
+ key = SETTINGS_KEYS[ui.set_sel]
1132
+ if key == 'tts_enabled':
1133
+ ui.tts_enabled = not ui.tts_enabled
1134
+ elif key == 'auto_speak':
1135
+ ui.auto_speak = not ui.auto_speak
1136
+ elif key == 'listening':
1137
+ ui.listening = not ui.listening
1138
+ st = 'on' if ui.listening else 'off'
1139
+ ui.chat_log.append(('info', f'Listening {st}.'))
1140
+ elif c == 'e':
1141
+ key = SETTINGS_KEYS[ui.set_sel]
1142
+ if key in ('silence_timeout', 'vad_threshold'):
1143
+ ui.editing = True
1144
+ ui.edit_key = key
1145
+ ui.edit_buf = str(ui.silence_timeout if key == 'silence_timeout' else ui.vad_threshold)
1146
+ return True
1147
+
1148
+ # ================================================================
1149
+ # Welcome
1150
+ # ================================================================
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 "?"}'))
1153
+ if AUDIO_AVAILABLE:
1154
+ ui.chat_log.append(('info', 'Listening for speech. Just start talking, or type text.'))
1155
+ ui.chat_log.append(('info', 'Ctrl+L to pause/resume listening. Ctrl+S to change settings.'))
1156
+ else:
1157
+ ui.chat_log.append(('info', 'Audio not available. Text mode only. Ctrl+S to change settings.'))
1158
+ if loaded_chunks:
1159
+ ui.chat_log.append(('info', f'{len(loaded_chunks)} files loaded for context.'))
1160
+
1161
+ # Start VAD listener thread
1162
+ _listener_thread = None
1163
+ if AUDIO_AVAILABLE and vad_model is not None:
1164
+ _listener_thread = threading.Thread(target=vad_listener_loop, daemon=True)
1165
+ _listener_thread.start()
1166
+
1167
+ # ================================================================
1168
+ # Main loop
1169
+ # ================================================================
1170
+ fd = sys.stdin.fileno()
1171
+ old_settings = termios.tcgetattr(fd)
1172
+ try:
1173
+ tty.setcbreak(fd)
1174
+ sys.stdout.write('\033[?25l\033[2J')
1175
+ running = True
1176
+ while running:
1177
+ render()
1178
+ if ui.thinking or ui.recording or ui.transcribing or ui.speaking or ui.listening:
1179
+ ui.spinner_frame += 1
1180
+ if _sel.select([fd], [], [], 0.15)[0]:
1181
+ c = os.read(fd, 1).decode('latin-1')
1182
+ running = handle_key(c, fd)
1183
+ finally:
1184
+ ui.listen_stop = True
1185
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1186
+ sys.stdout.write('\033[?25h\033[2J\033[H')
1187
+ sys.stdout.flush()
1188
+
1189
+ context['output'] = "Exited yap mode."
1190
+ context['messages'] = messages