npcsh 1.1.19__py3-none-any.whl → 1.1.21__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 (173) hide show
  1. npcsh/_state.py +16 -78
  2. npcsh/diff_viewer.py +3 -3
  3. npcsh/npc_team/jinxs/lib/core/compress.jinx +373 -85
  4. npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +18 -7
  5. npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +18 -7
  6. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +20 -9
  7. npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +53 -15
  8. npcsh/npc_team/jinxs/{bin → lib/utils}/benchmark.jinx +2 -2
  9. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +393 -317
  10. npcsh/npc_team/jinxs/lib/utils/models.jinx +343 -0
  11. npcsh/npc_team/jinxs/{bin → lib/utils}/setup.jinx +8 -7
  12. npcsh/npc_team/jinxs/modes/alicanto.jinx +1573 -296
  13. npcsh/npc_team/jinxs/modes/arxiv.jinx +6 -6
  14. npcsh/npc_team/jinxs/modes/config_tui.jinx +300 -0
  15. npcsh/npc_team/jinxs/modes/corca.jinx +4 -4
  16. npcsh/npc_team/jinxs/modes/git.jinx +795 -0
  17. npcsh/npc_team/jinxs/modes/guac.jinx +4 -4
  18. npcsh/npc_team/jinxs/modes/kg.jinx +941 -0
  19. npcsh/npc_team/jinxs/modes/memories.jinx +414 -0
  20. npcsh/npc_team/jinxs/modes/nql.jinx +460 -0
  21. npcsh/npc_team/jinxs/modes/papers.jinx +578 -0
  22. npcsh/npc_team/jinxs/modes/plonk.jinx +490 -304
  23. npcsh/npc_team/jinxs/modes/pti.jinx +1 -1
  24. npcsh/npc_team/jinxs/modes/reattach.jinx +4 -4
  25. npcsh/npc_team/jinxs/modes/spool.jinx +4 -4
  26. npcsh/npc_team/jinxs/modes/team.jinx +504 -0
  27. npcsh/npc_team/jinxs/modes/vixynt.jinx +388 -0
  28. npcsh/npc_team/jinxs/modes/wander.jinx +455 -182
  29. npcsh/npc_team/jinxs/modes/yap.jinx +10 -3
  30. npcsh/npcsh.py +112 -47
  31. npcsh/routes.py +12 -3
  32. npcsh/salmon_simulation.py +0 -0
  33. npcsh-1.1.21.data/data/npcsh/npc_team/alicanto.jinx +1633 -0
  34. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/arxiv.jinx +6 -6
  35. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/benchmark.jinx +2 -2
  36. npcsh-1.1.21.data/data/npcsh/npc_team/compress.jinx +428 -0
  37. npcsh-1.1.21.data/data/npcsh/npc_team/config_tui.jinx +300 -0
  38. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca.jinx +4 -4
  39. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/db_search.jinx +18 -7
  40. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/file_search.jinx +18 -7
  41. npcsh-1.1.21.data/data/npcsh/npc_team/git.jinx +795 -0
  42. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/guac.jinx +4 -4
  43. npcsh-1.1.21.data/data/npcsh/npc_team/jinxs.jinx +407 -0
  44. npcsh-1.1.21.data/data/npcsh/npc_team/kg.jinx +941 -0
  45. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/kg_search.jinx +20 -9
  46. npcsh-1.1.21.data/data/npcsh/npc_team/memories.jinx +414 -0
  47. npcsh-1.1.21.data/data/npcsh/npc_team/models.jinx +343 -0
  48. npcsh-1.1.21.data/data/npcsh/npc_team/nql.jinx +460 -0
  49. npcsh-1.1.21.data/data/npcsh/npc_team/papers.jinx +578 -0
  50. npcsh-1.1.21.data/data/npcsh/npc_team/plonk.jinx +565 -0
  51. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/pti.jinx +1 -1
  52. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/reattach.jinx +4 -4
  53. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/setup.jinx +8 -7
  54. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/spool.jinx +4 -4
  55. npcsh-1.1.21.data/data/npcsh/npc_team/team.jinx +504 -0
  56. npcsh-1.1.21.data/data/npcsh/npc_team/vixynt.jinx +388 -0
  57. npcsh-1.1.21.data/data/npcsh/npc_team/wander.jinx +728 -0
  58. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/web_search.jinx +53 -15
  59. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/yap.jinx +10 -3
  60. {npcsh-1.1.19.dist-info → npcsh-1.1.21.dist-info}/METADATA +2 -2
  61. {npcsh-1.1.19.dist-info → npcsh-1.1.21.dist-info}/RECORD +147 -148
  62. npcsh-1.1.21.dist-info/entry_points.txt +11 -0
  63. npcsh/npc_team/jinxs/bin/config_tui.jinx +0 -299
  64. npcsh/npc_team/jinxs/bin/memories.jinx +0 -316
  65. npcsh/npc_team/jinxs/bin/nql.jinx +0 -141
  66. npcsh/npc_team/jinxs/bin/team_tui.jinx +0 -327
  67. npcsh/npc_team/jinxs/bin/vixynt.jinx +0 -122
  68. npcsh/npc_team/jinxs/lib/core/search/mem_review.jinx +0 -73
  69. npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +0 -388
  70. npcsh/npc_team/jinxs/lib/research/paper_search.jinx +0 -412
  71. npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +0 -386
  72. npcsh/npc_team/plonkjr.npc +0 -23
  73. npcsh-1.1.19.data/data/npcsh/npc_team/alicanto.jinx +0 -356
  74. npcsh-1.1.19.data/data/npcsh/npc_team/compress.jinx +0 -140
  75. npcsh-1.1.19.data/data/npcsh/npc_team/config_tui.jinx +0 -299
  76. npcsh-1.1.19.data/data/npcsh/npc_team/jinxs.jinx +0 -331
  77. npcsh-1.1.19.data/data/npcsh/npc_team/mem_review.jinx +0 -73
  78. npcsh-1.1.19.data/data/npcsh/npc_team/mem_search.jinx +0 -388
  79. npcsh-1.1.19.data/data/npcsh/npc_team/memories.jinx +0 -316
  80. npcsh-1.1.19.data/data/npcsh/npc_team/nql.jinx +0 -141
  81. npcsh-1.1.19.data/data/npcsh/npc_team/paper_search.jinx +0 -412
  82. npcsh-1.1.19.data/data/npcsh/npc_team/plonk.jinx +0 -379
  83. npcsh-1.1.19.data/data/npcsh/npc_team/plonkjr.npc +0 -23
  84. npcsh-1.1.19.data/data/npcsh/npc_team/semantic_scholar.jinx +0 -386
  85. npcsh-1.1.19.data/data/npcsh/npc_team/team_tui.jinx +0 -327
  86. npcsh-1.1.19.data/data/npcsh/npc_team/vixynt.jinx +0 -122
  87. npcsh-1.1.19.data/data/npcsh/npc_team/wander.jinx +0 -455
  88. npcsh-1.1.19.dist-info/entry_points.txt +0 -22
  89. /npcsh/npc_team/jinxs/lib/{orchestration → core}/convene.jinx +0 -0
  90. /npcsh/npc_team/jinxs/lib/{orchestration → core}/delegate.jinx +0 -0
  91. /npcsh/npc_team/jinxs/{bin → lib/core}/sample.jinx +0 -0
  92. /npcsh/npc_team/jinxs/{bin → lib/utils}/sync.jinx +0 -0
  93. /npcsh/npc_team/jinxs/{bin → modes}/roll.jinx +0 -0
  94. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
  95. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/alicanto.npc +0 -0
  96. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/alicanto.png +0 -0
  97. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  98. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  99. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/build.jinx +0 -0
  100. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/chat.jinx +0 -0
  101. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/click.jinx +0 -0
  102. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  103. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
  104. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
  105. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  106. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/compile.jinx +0 -0
  107. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/confirm.jinx +0 -0
  108. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/convene.jinx +0 -0
  109. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca.npc +0 -0
  110. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca.png +0 -0
  111. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/corca_example.png +0 -0
  112. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/delegate.jinx +0 -0
  113. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/edit_file.jinx +0 -0
  114. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
  115. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/frederic.npc +0 -0
  116. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/frederic4.png +0 -0
  117. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/guac.npc +0 -0
  118. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/guac.png +0 -0
  119. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/help.jinx +0 -0
  120. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  121. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/init.jinx +0 -0
  122. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/kadiefa.npc +0 -0
  123. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  124. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  125. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  126. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
  127. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  128. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/navigate.jinx +0 -0
  129. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/notify.jinx +0 -0
  130. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  131. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  132. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  133. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
  134. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/ots.jinx +0 -0
  135. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/paste.jinx +0 -0
  136. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/plonk.npc +0 -0
  137. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/plonk.png +0 -0
  138. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  139. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/python.jinx +0 -0
  140. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
  141. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/roll.jinx +0 -0
  142. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
  143. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sample.jinx +0 -0
  144. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  145. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/search.jinx +0 -0
  146. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/send_message.jinx +0 -0
  147. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/serve.jinx +0 -0
  148. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/set.jinx +0 -0
  149. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sh.jinx +0 -0
  150. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/shh.jinx +0 -0
  151. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sibiji.npc +0 -0
  152. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sibiji.png +0 -0
  153. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  154. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
  155. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/spool.png +0 -0
  156. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sql.jinx +0 -0
  157. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switch.jinx +0 -0
  158. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
  159. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
  160. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/switches.jinx +0 -0
  161. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/sync.jinx +0 -0
  162. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  163. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  164. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  165. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/usage.jinx +0 -0
  166. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  167. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/wait.jinx +0 -0
  168. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/write_file.jinx +0 -0
  169. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/yap.png +0 -0
  170. {npcsh-1.1.19.data → npcsh-1.1.21.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
  171. {npcsh-1.1.19.dist-info → npcsh-1.1.21.dist-info}/WHEEL +0 -0
  172. {npcsh-1.1.19.dist-info → npcsh-1.1.21.dist-info}/licenses/LICENSE +0 -0
  173. {npcsh-1.1.19.dist-info → npcsh-1.1.21.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,941 @@
1
+ jinx_name: kg
2
+ description: Interactive knowledge graph browser - explore facts, concepts, and links
3
+ interactive: true
4
+ inputs: []
5
+ steps:
6
+ - name: kg_browser
7
+ engine: python
8
+ code: |
9
+ import os
10
+ import sys
11
+ import tty
12
+ import termios
13
+ import select
14
+
15
+ if not sys.stdin.isatty():
16
+ context['output'] = "KG browser requires an interactive terminal."
17
+
18
+ else:
19
+ from sqlalchemy import create_engine, text
20
+
21
+ db_path = os.environ.get('NPCSH_DB_PATH', os.path.expanduser('~/npcsh_history.db'))
22
+ engine = create_engine(f'sqlite:///{db_path}')
23
+
24
+ # ── check tables exist ──────────────────────────────────
25
+ tables_ok = False
26
+ try:
27
+ with engine.connect() as conn:
28
+ conn.execute(text("SELECT 1 FROM kg_facts LIMIT 1"))
29
+ conn.execute(text("SELECT 1 FROM kg_concepts LIMIT 1"))
30
+ conn.execute(text("SELECT 1 FROM kg_links LIMIT 1"))
31
+ tables_ok = True
32
+ except Exception:
33
+ tables_ok = False
34
+
35
+ if not tables_ok:
36
+ context['output'] = "No knowledge graph data found. Use agent mode to build KG."
37
+
38
+ else:
39
+ # ── state ───────────────────────────────────────────
40
+ import math
41
+
42
+ class KGState:
43
+ def __init__(self):
44
+ self.tab = 0
45
+ self.tabs = ['Facts', 'Concepts', 'Links', 'Search', 'Graph']
46
+ self.sel = 0
47
+ self.scroll = 0
48
+ self.detail = False
49
+ self.detail_scroll = 0
50
+ self.status = ""
51
+
52
+ # data
53
+ self.facts = []
54
+ self.concepts = []
55
+ self.links = []
56
+ self.search_results = []
57
+
58
+ # stats
59
+ self.fact_count = 0
60
+ self.concept_count = 0
61
+ self.link_count = 0
62
+ self.max_gen = 0
63
+
64
+ # generation filter
65
+ self.gen_filter = None # None = all
66
+
67
+ # search
68
+ self.search_mode = False
69
+ self.search_buf = ""
70
+
71
+ # detail data cache
72
+ self.detail_lines = []
73
+
74
+ # concept link counts for display
75
+ self.concept_link_counts = {}
76
+
77
+ # graph view
78
+ self.graph_adj = {} # concept -> set of neighbors
79
+ self.graph_center = ''
80
+ self.graph_neighbors = []
81
+ self.graph_sel = 0
82
+ self.graph_history = [] # stack for back navigation
83
+
84
+ ui = KGState()
85
+
86
+ def term_size():
87
+ try:
88
+ s = os.get_terminal_size()
89
+ return s.columns, s.lines
90
+ except Exception:
91
+ return 80, 24
92
+
93
+ # ── database loading ─────────────────────────────────
94
+ def load_stats():
95
+ with engine.connect() as conn:
96
+ r = conn.execute(text("SELECT COUNT(*) FROM kg_facts"))
97
+ ui.fact_count = r.scalar() or 0
98
+ r = conn.execute(text("SELECT COUNT(*) FROM kg_concepts"))
99
+ ui.concept_count = r.scalar() or 0
100
+ r = conn.execute(text("SELECT COUNT(*) FROM kg_links"))
101
+ ui.link_count = r.scalar() or 0
102
+ r = conn.execute(text("SELECT COALESCE(MAX(generation), 0) FROM kg_facts"))
103
+ ui.max_gen = r.scalar() or 0
104
+
105
+ def load_facts():
106
+ ui.facts = []
107
+ with engine.connect() as conn:
108
+ if ui.gen_filter is not None:
109
+ r = conn.execute(text(
110
+ "SELECT statement, source_text, type, generation, origin "
111
+ "FROM kg_facts WHERE generation = :gen ORDER BY rowid DESC"
112
+ ), {"gen": ui.gen_filter})
113
+ else:
114
+ r = conn.execute(text(
115
+ "SELECT statement, source_text, type, generation, origin "
116
+ "FROM kg_facts ORDER BY rowid DESC"
117
+ ))
118
+ for row in r:
119
+ ui.facts.append({
120
+ 'statement': row.statement or '',
121
+ 'source_text': row.source_text or '',
122
+ 'type': row.type or '',
123
+ 'generation': row.generation if row.generation is not None else 0,
124
+ 'origin': row.origin or 'organic',
125
+ })
126
+
127
+ def load_concepts():
128
+ ui.concepts = []
129
+ ui.concept_link_counts = {}
130
+ with engine.connect() as conn:
131
+ if ui.gen_filter is not None:
132
+ r = conn.execute(text(
133
+ "SELECT name, generation, origin "
134
+ "FROM kg_concepts WHERE generation = :gen ORDER BY name"
135
+ ), {"gen": ui.gen_filter})
136
+ else:
137
+ r = conn.execute(text(
138
+ "SELECT name, generation, origin FROM kg_concepts ORDER BY name"
139
+ ))
140
+ for row in r:
141
+ ui.concepts.append({
142
+ 'name': row.name or '',
143
+ 'generation': row.generation if row.generation is not None else 0,
144
+ 'origin': row.origin or 'organic',
145
+ })
146
+ # count linked facts per concept
147
+ r2 = conn.execute(text(
148
+ "SELECT target, COUNT(*) as cnt FROM kg_links "
149
+ "WHERE type = 'fact_to_concept' GROUP BY target"
150
+ ))
151
+ for row in r2:
152
+ ui.concept_link_counts[row.target] = row.cnt
153
+
154
+ def load_links():
155
+ ui.links = []
156
+ with engine.connect() as conn:
157
+ r = conn.execute(text(
158
+ "SELECT source, target, type FROM kg_links ORDER BY rowid DESC"
159
+ ))
160
+ for row in r:
161
+ ui.links.append({
162
+ 'source': row.source or '',
163
+ 'target': row.target or '',
164
+ 'type': row.type or '',
165
+ })
166
+
167
+ def do_search(query):
168
+ ui.search_results = []
169
+ pat = f"%{query}%"
170
+ with engine.connect() as conn:
171
+ r = conn.execute(text(
172
+ "SELECT statement, source_text, type, generation, origin "
173
+ "FROM kg_facts WHERE statement LIKE :pat ORDER BY rowid DESC"
174
+ ), {"pat": pat})
175
+ for row in r:
176
+ ui.search_results.append({
177
+ 'kind': 'fact',
178
+ 'text': row.statement or '',
179
+ 'source_text': row.source_text or '',
180
+ 'type': row.type or '',
181
+ 'generation': row.generation if row.generation is not None else 0,
182
+ 'origin': row.origin or 'organic',
183
+ })
184
+ r2 = conn.execute(text(
185
+ "SELECT name, generation, origin "
186
+ "FROM kg_concepts WHERE name LIKE :q ORDER BY name"
187
+ ), {"q": q})
188
+ for row in r2:
189
+ ui.search_results.append({
190
+ 'kind': 'concept',
191
+ 'text': row.name or '',
192
+ 'source_text': '',
193
+ 'type': '',
194
+ 'generation': row.generation if row.generation is not None else 0,
195
+ 'origin': row.origin or 'organic',
196
+ })
197
+
198
+ def load_fact_detail(idx):
199
+ """Build detail lines for a fact."""
200
+ if idx >= len(ui.facts):
201
+ ui.detail_lines = ["(no data)"]
202
+ return
203
+ fact = ui.facts[idx]
204
+ lines = []
205
+ lines.append(("\033[1mFact:\033[0m", ""))
206
+ # word-wrap statement
207
+ W, _ = term_size()
208
+ wrap_w = max(20, W - 6)
209
+ stmt = fact['statement']
210
+ while len(stmt) > wrap_w:
211
+ lines.append((" " + stmt[:wrap_w], ""))
212
+ stmt = stmt[wrap_w:]
213
+ if stmt:
214
+ lines.append((" " + stmt, ""))
215
+ lines.append(("", ""))
216
+ if fact['source_text']:
217
+ lines.append(("\033[1mSource text:\033[0m", ""))
218
+ st = fact['source_text']
219
+ while len(st) > wrap_w:
220
+ lines.append((" " + st[:wrap_w], ""))
221
+ st = st[wrap_w:]
222
+ if st:
223
+ lines.append((" " + st, ""))
224
+ lines.append(("", ""))
225
+ lines.append((f"\033[1mType:\033[0m {fact['type']}", ""))
226
+ lines.append((f"\033[1mGeneration:\033[0m {fact['generation']}", ""))
227
+ lines.append((f"\033[1mOrigin:\033[0m {fact['origin']}", ""))
228
+ lines.append(("", ""))
229
+ # linked concepts
230
+ lines.append(("\033[1mLinked Concepts:\033[0m", ""))
231
+ with engine.connect() as conn:
232
+ r = conn.execute(text(
233
+ "SELECT target FROM kg_links "
234
+ "WHERE source = :src AND type = 'fact_to_concept'"
235
+ ), {"src": fact['statement']})
236
+ found = False
237
+ for row in r:
238
+ lines.append((" \033[36m" + (row.target or '') + "\033[0m", ""))
239
+ found = True
240
+ if not found:
241
+ lines.append((" \033[90m(none)\033[0m", ""))
242
+ ui.detail_lines = lines
243
+
244
+ def load_concept_detail(idx):
245
+ """Build detail lines for a concept."""
246
+ if idx >= len(ui.concepts):
247
+ ui.detail_lines = ["(no data)"]
248
+ return
249
+ concept = ui.concepts[idx]
250
+ lines = []
251
+ lines.append(("\033[1mConcept:\033[0m \033[36m" + concept['name'] + "\033[0m", ""))
252
+ lines.append((f"\033[1mGeneration:\033[0m {concept['generation']}", ""))
253
+ lines.append((f"\033[1mOrigin:\033[0m {concept['origin']}", ""))
254
+ lines.append(("", ""))
255
+ # linked facts
256
+ lines.append(("\033[1mLinked Facts:\033[0m", ""))
257
+ W, _ = term_size()
258
+ wrap_w = max(20, W - 6)
259
+ with engine.connect() as conn:
260
+ r = conn.execute(text(
261
+ "SELECT source FROM kg_links "
262
+ "WHERE target = :tgt AND type = 'fact_to_concept'"
263
+ ), {"tgt": concept['name']})
264
+ found = False
265
+ for row in r:
266
+ s = row.source or ''
267
+ if len(s) > wrap_w:
268
+ s = s[:wrap_w - 3] + '...'
269
+ lines.append((" " + s, ""))
270
+ found = True
271
+ if not found:
272
+ lines.append((" \033[90m(none)\033[0m", ""))
273
+ lines.append(("", ""))
274
+ # linked concepts
275
+ lines.append(("\033[1mLinked Concepts:\033[0m", ""))
276
+ with engine.connect() as conn:
277
+ r = conn.execute(text(
278
+ "SELECT target FROM kg_links "
279
+ "WHERE source = :name AND type = 'concept_to_concept' "
280
+ "UNION "
281
+ "SELECT source FROM kg_links "
282
+ "WHERE target = :name AND type = 'concept_to_concept'"
283
+ ), {"name": concept['name']})
284
+ found2 = False
285
+ for row in r:
286
+ lines.append((" \033[36m" + (row[0] or '') + "\033[0m", ""))
287
+ found2 = True
288
+ if not found2:
289
+ lines.append((" \033[90m(none)\033[0m", ""))
290
+ ui.detail_lines = lines
291
+
292
+ def load_search_detail(idx):
293
+ """Build detail for a search result item."""
294
+ if idx >= len(ui.search_results):
295
+ ui.detail_lines = ["(no data)"]
296
+ return
297
+ item = ui.search_results[idx]
298
+ if item['kind'] == 'fact':
299
+ # find it in full facts list by statement match and delegate
300
+ for fi, f in enumerate(ui.facts):
301
+ if f['statement'] == item['text']:
302
+ load_fact_detail(fi)
303
+ return
304
+ # fallback: build inline
305
+ ui.detail_lines = [
306
+ ("\033[1mFact:\033[0m " + item['text'], ""),
307
+ (f"\033[1mOrigin:\033[0m {item['origin']}", ""),
308
+ (f"\033[1mGeneration:\033[0m {item['generation']}", ""),
309
+ ]
310
+ else:
311
+ for ci, c in enumerate(ui.concepts):
312
+ if c['name'] == item['text']:
313
+ load_concept_detail(ci)
314
+ return
315
+ ui.detail_lines = [
316
+ ("\033[1mConcept:\033[0m \033[36m" + item['text'] + "\033[0m", ""),
317
+ (f"\033[1mOrigin:\033[0m {item['origin']}", ""),
318
+ (f"\033[1mGeneration:\033[0m {item['generation']}", ""),
319
+ ]
320
+
321
+ def load_all_data():
322
+ load_stats()
323
+ load_facts()
324
+ load_concepts()
325
+ load_links()
326
+ load_graph_data()
327
+
328
+ def load_graph_data():
329
+ """Load concept-to-concept adjacency for graph view."""
330
+ ui.graph_adj = {}
331
+ with engine.connect() as conn:
332
+ r = conn.execute(text(
333
+ "SELECT source, target FROM kg_links "
334
+ "WHERE type = 'concept_to_concept'"
335
+ ))
336
+ for row in r:
337
+ src = row.source or ''
338
+ tgt = row.target or ''
339
+ if src not in ui.graph_adj:
340
+ ui.graph_adj[src] = set()
341
+ if tgt not in ui.graph_adj:
342
+ ui.graph_adj[tgt] = set()
343
+ ui.graph_adj[src].add(tgt)
344
+ ui.graph_adj[tgt].add(src)
345
+ # Also add concepts with fact links but no concept links
346
+ for c in ui.concepts:
347
+ if c['name'] not in ui.graph_adj:
348
+ ui.graph_adj[c['name']] = set()
349
+ # Pick the most connected concept as initial center
350
+ if ui.graph_adj:
351
+ ui.graph_center = max(
352
+ ui.graph_adj.keys(),
353
+ key=lambda k: len(ui.graph_adj[k])
354
+ )
355
+ else:
356
+ ui.graph_center = ui.concepts[0]['name'] if ui.concepts else ''
357
+ ui.graph_history = []
358
+ update_graph_neighbors()
359
+
360
+ def update_graph_neighbors():
361
+ """Update neighbors list for current graph center."""
362
+ if ui.graph_center in ui.graph_adj:
363
+ ui.graph_neighbors = sorted(ui.graph_adj[ui.graph_center])
364
+ else:
365
+ ui.graph_neighbors = []
366
+ ui.graph_sel = 0
367
+
368
+ # ── rendering ───────────────────────────────────────
369
+ def wline(row, txt):
370
+ return f"\033[{row};1H\033[K{txt}"
371
+
372
+ def render():
373
+ W, H = term_size()
374
+ out = []
375
+ out.append("\033[H")
376
+
377
+ # ── header ──
378
+ hdr = " Knowledge Graph "
379
+ pad = '=' * W
380
+ out.append(wline(1, f"\033[7;1m{pad}\033[0m"))
381
+ out.append(f"\033[1;{max(1,(W - len(hdr))//2)}H\033[7;1m{hdr}\033[0m")
382
+
383
+ # ── tabs ──
384
+ tb = ""
385
+ for i, t in enumerate(ui.tabs):
386
+ if i == ui.tab:
387
+ tb += f"\033[7;1m [{t}] \033[0m"
388
+ else:
389
+ tb += f" {t} "
390
+ out.append(wline(2, f" {tb}"))
391
+ out.append(wline(3, f"\033[90m{'─' * W}\033[0m"))
392
+
393
+ # ── stats line ──
394
+ gf = f"Gen: {ui.gen_filter}" if ui.gen_filter is not None else "Gen: all"
395
+ stats_line = (
396
+ f" {gf} Facts: {ui.fact_count} "
397
+ f"Concepts: {ui.concept_count} Links: {ui.link_count}"
398
+ )
399
+ out.append(wline(4, f"\033[90m{stats_line}\033[0m"))
400
+ out.append(wline(5, f"\033[90m{'─' * W}\033[0m"))
401
+
402
+ # ── body ──
403
+ body_start = 6
404
+ body_end = H - 3
405
+ body_h = body_end - body_start + 1
406
+ if body_h < 1:
407
+ body_h = 1
408
+
409
+ if ui.search_mode:
410
+ render_search_input(out, W, body_start, body_h)
411
+ elif ui.detail:
412
+ render_detail(out, W, body_start, body_h)
413
+ elif ui.tab == 0:
414
+ render_facts(out, W, body_start, body_h)
415
+ elif ui.tab == 1:
416
+ render_concepts(out, W, body_start, body_h)
417
+ elif ui.tab == 2:
418
+ render_links_tab(out, W, body_start, body_h)
419
+ elif ui.tab == 3:
420
+ render_search_results(out, W, body_start, body_h)
421
+ elif ui.tab == 4:
422
+ render_graph(out, W, body_start, body_h)
423
+
424
+ # ── separator ──
425
+ out.append(wline(H - 2, f"\033[90m{'─' * W}\033[0m"))
426
+
427
+ # ── status ──
428
+ if ui.status:
429
+ out.append(wline(H - 1, f" \033[33m{ui.status[:W-2]}\033[0m"))
430
+ else:
431
+ out.append(wline(H - 1, ""))
432
+
433
+ # ── footer ──
434
+ if ui.search_mode:
435
+ foot = " Type query, [Enter] Search [Esc] Cancel"
436
+ elif ui.detail:
437
+ foot = " [j/k] Scroll [q/Esc] Back"
438
+ elif ui.tab == 4:
439
+ foot = " [Tab] Switch [j/k] Select [Enter] Center [Backspace] Back [q] Quit"
440
+ else:
441
+ foot = " [Tab] Switch [j/k] Nav [Enter] Detail [/] Search [g] Gen [q] Quit"
442
+ out.append(wline(H, f"\033[7m{foot[:W].ljust(W)}\033[0m"))
443
+
444
+ sys.stdout.write(''.join(out))
445
+ sys.stdout.flush()
446
+
447
+ # ── tab renderers ───────────────────────────────────
448
+
449
+ def render_facts(out, W, start, body_h):
450
+ vis = ui.facts[ui.scroll:ui.scroll + body_h]
451
+ for r in range(body_h):
452
+ row = start + r
453
+ i = r + ui.scroll
454
+ if r >= len(vis):
455
+ out.append(wline(row, ""))
456
+ continue
457
+ f = vis[r]
458
+ stmt = f['statement'].replace('\n', ' ')
459
+ origin_tag = f"[{f['origin']}]"
460
+ gen_tag = f"gen:{f['generation']}"
461
+ tag = f"\033[90m{origin_tag} {gen_tag}\033[0m"
462
+ max_stmt = W - len(origin_tag) - len(gen_tag) - 10
463
+ if max_stmt < 10:
464
+ max_stmt = 10
465
+ if len(stmt) > max_stmt:
466
+ stmt = stmt[:max_stmt - 3] + '...'
467
+ if i == ui.sel:
468
+ line = f" > {stmt}"
469
+ # pad to W then add tag
470
+ padded = line[:W-len(origin_tag)-len(gen_tag)-4].ljust(W-len(origin_tag)-len(gen_tag)-4)
471
+ out.append(wline(row, f"\033[7m{padded} {origin_tag} {gen_tag}\033[0m"))
472
+ else:
473
+ out.append(wline(row, f" {stmt} {tag}"))
474
+ if not ui.facts:
475
+ out.append(wline(start, " \033[90mNo facts found.\033[0m"))
476
+ for r in range(1, body_h):
477
+ out.append(wline(start + r, ""))
478
+
479
+ def render_concepts(out, W, start, body_h):
480
+ vis = ui.concepts[ui.scroll:ui.scroll + body_h]
481
+ for r in range(body_h):
482
+ row = start + r
483
+ i = r + ui.scroll
484
+ if r >= len(vis):
485
+ out.append(wline(row, ""))
486
+ continue
487
+ c = vis[r]
488
+ name = c['name']
489
+ lc = ui.concept_link_counts.get(name, 0)
490
+ origin_tag = f"[{c['origin']}]"
491
+ gen_tag = f"gen:{c['generation']}"
492
+ info = f"\033[90m({lc} facts) {origin_tag} {gen_tag}\033[0m"
493
+ max_name = W - 30
494
+ if max_name < 10:
495
+ max_name = 10
496
+ if len(name) > max_name:
497
+ name = name[:max_name - 3] + '...'
498
+ if i == ui.sel:
499
+ out.append(wline(row, f"\033[7m > {name:<{max_name}} ({lc} facts) {origin_tag} {gen_tag}\033[0m"))
500
+ else:
501
+ out.append(wline(row, f" \033[36m{name:<{max_name}}\033[0m {info}"))
502
+ if not ui.concepts:
503
+ out.append(wline(start, " \033[90mNo concepts found.\033[0m"))
504
+ for r in range(1, body_h):
505
+ out.append(wline(start + r, ""))
506
+
507
+ def render_links_tab(out, W, start, body_h):
508
+ vis = ui.links[ui.scroll:ui.scroll + body_h]
509
+ for r in range(body_h):
510
+ row = start + r
511
+ i = r + ui.scroll
512
+ if r >= len(vis):
513
+ out.append(wline(row, ""))
514
+ continue
515
+ lnk = vis[r]
516
+ src = lnk['source'].replace('\n', ' ')
517
+ tgt = lnk['target'].replace('\n', ' ')
518
+ lt = lnk['type']
519
+ max_w = (W - 12) // 2
520
+ if max_w < 10:
521
+ max_w = 10
522
+ if len(src) > max_w:
523
+ src = src[:max_w - 3] + '...'
524
+ if len(tgt) > max_w:
525
+ tgt = tgt[:max_w - 3] + '...'
526
+ arrow = f"\033[33m -> \033[0m"
527
+ type_tag = f"\033[90m[{lt}]\033[0m"
528
+ if i == ui.sel:
529
+ out.append(wline(row, f"\033[7m > {src} -> {tgt} [{lt}]\033[0m"))
530
+ else:
531
+ out.append(wline(row, f" {src}{arrow}{tgt} {type_tag}"))
532
+ if not ui.links:
533
+ out.append(wline(start, " \033[90mNo links found.\033[0m"))
534
+ for r in range(1, body_h):
535
+ out.append(wline(start + r, ""))
536
+
537
+ def render_search_results(out, W, start, body_h):
538
+ items = ui.search_results
539
+ vis = items[ui.scroll:ui.scroll + body_h]
540
+ for r in range(body_h):
541
+ row = start + r
542
+ i = r + ui.scroll
543
+ if r >= len(vis):
544
+ out.append(wline(row, ""))
545
+ continue
546
+ item = vis[r]
547
+ kind_tag = f"\033[90m[{item['kind']}]\033[0m"
548
+ txt = item['text'].replace('\n', ' ')
549
+ origin_tag = f"[{item['origin']}]"
550
+ gen_tag = f"gen:{item['generation']}"
551
+ max_t = W - 30
552
+ if max_t < 10:
553
+ max_t = 10
554
+ if len(txt) > max_t:
555
+ txt = txt[:max_t - 3] + '...'
556
+ if item['kind'] == 'concept':
557
+ txt = f"\033[36m{txt}\033[0m"
558
+ if i == ui.sel:
559
+ raw_txt = item['text'].replace('\n', ' ')
560
+ if len(raw_txt) > max_t:
561
+ raw_txt = raw_txt[:max_t - 3] + '...'
562
+ out.append(wline(row, f"\033[7m > [{item['kind']}] {raw_txt} {origin_tag} {gen_tag}\033[0m"))
563
+ else:
564
+ out.append(wline(row, f" {kind_tag} {txt} \033[90m{origin_tag} {gen_tag}\033[0m"))
565
+ if not items:
566
+ out.append(wline(start, " \033[90mNo search results. Press / to search.\033[0m"))
567
+ for r in range(1, body_h):
568
+ out.append(wline(start + r, ""))
569
+
570
+ def render_search_input(out, W, start, body_h):
571
+ out.append(wline(start, f" \033[1mSearch:\033[0m {ui.search_buf}\033[7m \033[0m"))
572
+ for r in range(1, body_h):
573
+ out.append(wline(start + r, ""))
574
+
575
+ def render_detail(out, W, start, body_h):
576
+ lines = ui.detail_lines
577
+ vis = lines[ui.detail_scroll:ui.detail_scroll + body_h]
578
+ for r in range(body_h):
579
+ row = start + r
580
+ if r >= len(vis):
581
+ out.append(wline(row, ""))
582
+ continue
583
+ line_data = vis[r]
584
+ if isinstance(line_data, tuple):
585
+ txt = line_data[0]
586
+ else:
587
+ txt = str(line_data)
588
+ out.append(wline(row, f" {txt}"))
589
+
590
+ # ── graph rendering ─────────────────────────────────
591
+
592
+ def render_graph(out, W, start, body_h):
593
+ """Render a center+spoke graph of concepts."""
594
+ if not ui.graph_center:
595
+ out.append(wline(start, " \033[90mNo concepts available for graph.\033[0m"))
596
+ for r in range(1, body_h):
597
+ out.append(wline(start + r, ""))
598
+ return
599
+
600
+ # Build a character canvas
601
+ canvas = [[' '] * W for _ in range(body_h)]
602
+ # Color map: parallel array of ANSI codes per cell (empty = default)
603
+ colors = [['' for _ in range(W)] for _ in range(body_h)]
604
+
605
+ cx = W // 2
606
+ cy = body_h // 2
607
+
608
+ # Draw center node
609
+ clabel = ui.graph_center
610
+ if len(clabel) > W // 3:
611
+ clabel = clabel[:W // 3 - 2] + '..'
612
+ ctext = f"\u2503 {clabel} \u2503"
613
+ ctop = "\u250f" + "\u2501" * (len(ctext) - 2) + "\u2513"
614
+ cbot = "\u2517" + "\u2501" * (len(ctext) - 2) + "\u251b"
615
+ cx0 = cx - len(ctext) // 2
616
+
617
+ # Place center box (3 rows)
618
+ for i, ch in enumerate(ctop):
619
+ if 0 <= cx0 + i < W and 0 <= cy - 1 < body_h:
620
+ canvas[cy - 1][cx0 + i] = ch
621
+ colors[cy - 1][cx0 + i] = '\033[1;33m'
622
+ for i, ch in enumerate(ctext):
623
+ if 0 <= cx0 + i < W and 0 <= cy < body_h:
624
+ canvas[cy][cx0 + i] = ch
625
+ colors[cy][cx0 + i] = '\033[1;33m'
626
+ for i, ch in enumerate(cbot):
627
+ if 0 <= cx0 + i < W and 0 <= cy + 1 < body_h:
628
+ canvas[cy + 1][cx0 + i] = ch
629
+ colors[cy + 1][cx0 + i] = '\033[1;33m'
630
+
631
+ # Center box bounds (for avoiding overwrite)
632
+ cbox_left = cx0
633
+ cbox_right = cx0 + len(ctext)
634
+ cbox_top = cy - 1
635
+ cbox_bot = cy + 1
636
+
637
+ neighbors = ui.graph_neighbors
638
+ n = len(neighbors)
639
+
640
+ if n > 0:
641
+ # Calculate radius based on terminal size
642
+ rx = min(W // 3, 30)
643
+ ry = min(body_h // 3, max(3, body_h // 4))
644
+
645
+ for idx, nbr in enumerate(neighbors):
646
+ angle = (2 * math.pi * idx) / n - math.pi / 2
647
+ nx = cx + int(rx * math.cos(angle))
648
+ ny = cy + int(ry * math.sin(angle))
649
+
650
+ # Clamp neighbor position
651
+ nlabel = nbr
652
+ if len(nlabel) > 18:
653
+ nlabel = nlabel[:15] + '..'
654
+ if idx == ui.graph_sel:
655
+ ntext = f"[{nlabel}]"
656
+ ncolor = '\033[7;36m'
657
+ else:
658
+ ntext = f"({nlabel})"
659
+ ncolor = '\033[36m'
660
+
661
+ nl = len(ntext)
662
+ nlx = max(0, min(W - nl, nx - nl // 2))
663
+ nly = max(0, min(body_h - 1, ny))
664
+
665
+ # Place neighbor label
666
+ for i, ch in enumerate(ntext):
667
+ if 0 <= nlx + i < W:
668
+ canvas[nly][nlx + i] = ch
669
+ colors[nly][nlx + i] = ncolor
670
+
671
+ # Draw edge from center to neighbor
672
+ edge_x1, edge_y1 = cx, cy
673
+ edge_x2, edge_y2 = nlx + nl // 2, nly
674
+ draw_edge(canvas, colors, W, body_h,
675
+ edge_x1, edge_y1, edge_x2, edge_y2,
676
+ cbox_left, cbox_right, cbox_top, cbox_bot)
677
+
678
+ # Fact count for center
679
+ fc = ui.concept_link_counts.get(ui.graph_center, 0)
680
+ info = f"{fc} linked facts, {n} connected concepts"
681
+ info_x = max(0, cx - len(info) // 2)
682
+ info_y = min(body_h - 1, cy + 3)
683
+ for i, ch in enumerate(info):
684
+ if 0 <= info_x + i < W and canvas[info_y][info_x + i] == ' ':
685
+ canvas[info_y][info_x + i] = ch
686
+ colors[info_y][info_x + i] = '\033[90m'
687
+
688
+ # Render canvas to output
689
+ for r in range(body_h):
690
+ line = ""
691
+ cur_color = ''
692
+ for c_idx in range(W):
693
+ cell_color = colors[r][c_idx]
694
+ if cell_color != cur_color:
695
+ if cur_color:
696
+ line += '\033[0m'
697
+ line += cell_color
698
+ cur_color = cell_color
699
+ line += canvas[r][c_idx]
700
+ if cur_color:
701
+ line += '\033[0m'
702
+ out.append(wline(start + r, line))
703
+
704
+ def draw_edge(canvas, colors, W, H, x1, y1, x2, y2,
705
+ bl, br, bt, bb):
706
+ """Draw a line between two points, skipping the center box area."""
707
+ dx = x2 - x1
708
+ dy = y2 - y1
709
+ steps = max(abs(dx), abs(dy))
710
+ if steps == 0:
711
+ return
712
+ xi = dx / steps
713
+ yi = dy / steps
714
+ x, y = float(x1), float(y1)
715
+ for _ in range(steps):
716
+ x += xi
717
+ y += yi
718
+ ix, iy = int(round(x)), int(round(y))
719
+ if not (0 <= ix < W and 0 <= iy < H):
720
+ continue
721
+ # Skip center box area
722
+ if bl <= ix < br and bt <= iy <= bb:
723
+ continue
724
+ # Only draw on empty cells
725
+ if canvas[iy][ix] != ' ':
726
+ continue
727
+ # Pick character based on direction
728
+ adx, ady = abs(dx), abs(dy)
729
+ if ady < adx * 0.3:
730
+ ch = '\u2500' # ─
731
+ elif adx < ady * 0.3:
732
+ ch = '\u2502' # │
733
+ elif (dx > 0) == (dy > 0):
734
+ ch = '\u00b7' # ·
735
+ else:
736
+ ch = '\u00b7' # ·
737
+ canvas[iy][ix] = ch
738
+ colors[iy][ix] = '\033[90m'
739
+
740
+ # ── input handling ──────────────────────────────────
741
+
742
+ def handle(c):
743
+ if ui.search_mode:
744
+ return handle_search_input(c)
745
+ if c == '\x1b':
746
+ return handle_esc()
747
+ if c == 'q':
748
+ if ui.detail:
749
+ ui.detail = False
750
+ ui.detail_scroll = 0
751
+ ui.status = ""
752
+ else:
753
+ return False
754
+ elif c == '\t':
755
+ ui.tab = (ui.tab + 1) % len(ui.tabs)
756
+ ui.sel = 0
757
+ ui.scroll = 0
758
+ ui.detail = False
759
+ ui.status = ""
760
+ elif c == 'k':
761
+ nav_up()
762
+ elif c == 'j':
763
+ nav_down()
764
+ elif c in ('\r', '\n'):
765
+ do_enter()
766
+ elif c == '/':
767
+ ui.search_mode = True
768
+ ui.search_buf = ""
769
+ ui.status = ""
770
+ elif c == 'g':
771
+ if ui.tab != 4:
772
+ cycle_gen()
773
+ elif c in ('\x7f', '\x08'):
774
+ # Backspace: go back in graph history
775
+ if ui.tab == 4 and ui.graph_history:
776
+ ui.graph_center = ui.graph_history.pop()
777
+ update_graph_neighbors()
778
+ ui.status = f"Back to: {ui.graph_center}"
779
+ return True
780
+
781
+ def handle_esc():
782
+ if select.select([fd], [], [], 0.05)[0]:
783
+ c2 = os.read(fd, 1).decode('latin-1')
784
+ if c2 == '[':
785
+ c3 = os.read(fd, 1).decode('latin-1')
786
+ if c3 == 'A':
787
+ nav_up()
788
+ elif c3 == 'B':
789
+ nav_down()
790
+ else:
791
+ if ui.detail:
792
+ ui.detail = False
793
+ ui.detail_scroll = 0
794
+ ui.status = ""
795
+ return True
796
+
797
+ def handle_search_input(c):
798
+ if c == '\x1b':
799
+ if select.select([fd], [], [], 0.05)[0]:
800
+ c2 = os.read(fd, 1).decode('latin-1')
801
+ if c2 == '[':
802
+ os.read(fd, 1).decode('latin-1')
803
+ else:
804
+ ui.search_mode = False
805
+ ui.search_buf = ""
806
+ ui.status = "Search cancelled"
807
+ elif c in ('\r', '\n'):
808
+ if ui.search_buf.strip():
809
+ do_search(ui.search_buf.strip())
810
+ ui.tab = 3
811
+ ui.sel = 0
812
+ ui.scroll = 0
813
+ ui.status = f"Found {len(ui.search_results)} results for '{ui.search_buf.strip()}'"
814
+ ui.search_mode = False
815
+ ui.search_buf = ""
816
+ elif c in ('\x7f', '\x08'):
817
+ ui.search_buf = ui.search_buf[:-1]
818
+ elif c == '\x15':
819
+ ui.search_buf = ""
820
+ elif len(c) == 1 and 32 <= ord(c) <= 126:
821
+ ui.search_buf += c
822
+ return True
823
+
824
+ def nav_up():
825
+ if ui.detail:
826
+ ui.detail_scroll = max(0, ui.detail_scroll - 1)
827
+ elif ui.tab == 4:
828
+ # Graph tab: cycle through neighbors
829
+ if ui.graph_neighbors:
830
+ ui.graph_sel = (ui.graph_sel - 1) % len(ui.graph_neighbors)
831
+ ui.status = ui.graph_neighbors[ui.graph_sel]
832
+ else:
833
+ ui.sel = max(0, ui.sel - 1)
834
+ if ui.sel < ui.scroll:
835
+ ui.scroll = ui.sel
836
+ ui.status = ""
837
+
838
+ def nav_down():
839
+ _, H = term_size()
840
+ body_h = H - 8
841
+ if body_h < 1:
842
+ body_h = 1
843
+ if ui.detail:
844
+ max_scroll = max(0, len(ui.detail_lines) - body_h)
845
+ ui.detail_scroll = min(max_scroll, ui.detail_scroll + 1)
846
+ elif ui.tab == 4:
847
+ # Graph tab: cycle through neighbors
848
+ if ui.graph_neighbors:
849
+ ui.graph_sel = (ui.graph_sel + 1) % len(ui.graph_neighbors)
850
+ ui.status = ui.graph_neighbors[ui.graph_sel]
851
+ else:
852
+ mx = max(0, current_list_len() - 1)
853
+ ui.sel = min(mx, ui.sel + 1)
854
+ if ui.sel >= ui.scroll + body_h:
855
+ ui.scroll = ui.sel - body_h + 1
856
+ ui.status = ""
857
+
858
+ def current_list_len():
859
+ if ui.tab == 0:
860
+ return len(ui.facts)
861
+ elif ui.tab == 1:
862
+ return len(ui.concepts)
863
+ elif ui.tab == 2:
864
+ return len(ui.links)
865
+ elif ui.tab == 3:
866
+ return len(ui.search_results)
867
+ return 0
868
+
869
+ def do_enter():
870
+ if ui.detail:
871
+ return
872
+ if ui.tab == 0 and ui.facts:
873
+ load_fact_detail(ui.sel)
874
+ ui.detail = True
875
+ ui.detail_scroll = 0
876
+ elif ui.tab == 1 and ui.concepts:
877
+ load_concept_detail(ui.sel)
878
+ ui.detail = True
879
+ ui.detail_scroll = 0
880
+ elif ui.tab == 2 and ui.links:
881
+ # for a link, show details of source item
882
+ lnk = ui.links[ui.sel]
883
+ lines = []
884
+ lines.append(("\033[1mLink:\033[0m", ""))
885
+ lines.append((f" \033[33mSource:\033[0m {lnk['source']}", ""))
886
+ lines.append((f" \033[33mTarget:\033[0m {lnk['target']}", ""))
887
+ lines.append((f" \033[33mType:\033[0m {lnk['type']}", ""))
888
+ ui.detail_lines = lines
889
+ ui.detail = True
890
+ ui.detail_scroll = 0
891
+ elif ui.tab == 3 and ui.search_results:
892
+ load_search_detail(ui.sel)
893
+ ui.detail = True
894
+ ui.detail_scroll = 0
895
+ elif ui.tab == 4 and ui.graph_neighbors:
896
+ # Recenter graph on selected neighbor
897
+ new_center = ui.graph_neighbors[ui.graph_sel]
898
+ ui.graph_history.append(ui.graph_center)
899
+ ui.graph_center = new_center
900
+ update_graph_neighbors()
901
+ ui.status = f"Centered: {new_center}"
902
+
903
+ def cycle_gen():
904
+ if ui.gen_filter is None:
905
+ ui.gen_filter = 0
906
+ elif ui.gen_filter >= ui.max_gen:
907
+ ui.gen_filter = None
908
+ else:
909
+ ui.gen_filter = ui.gen_filter + 1
910
+ ui.sel = 0
911
+ ui.scroll = 0
912
+ ui.detail = False
913
+ load_facts()
914
+ load_concepts()
915
+ if ui.gen_filter is not None:
916
+ ui.status = f"Filter: generation {ui.gen_filter}"
917
+ else:
918
+ ui.status = "Filter: all generations"
919
+
920
+ # ── main loop ──────────────────────────────────────
921
+ load_all_data()
922
+ fd = sys.stdin.fileno()
923
+ old_attrs = termios.tcgetattr(fd)
924
+
925
+ try:
926
+ tty.setcbreak(fd)
927
+ sys.stdout.write('\033[?25l')
928
+ sys.stdout.write('\033[2J\033[H')
929
+ sys.stdout.flush()
930
+ render()
931
+ while True:
932
+ c = os.read(fd, 1).decode('latin-1')
933
+ if not handle(c):
934
+ break
935
+ render()
936
+ finally:
937
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
938
+ sys.stdout.write('\033[?25h\033[2J\033[H')
939
+ sys.stdout.flush()
940
+
941
+ context['output'] = "KG browser closed."