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,795 @@
1
+ jinx_name: git
2
+ description: Interactive terminal UI for git status, staging, diffs, log, and commits
3
+ interactive: true
4
+ inputs:
5
+ - path: ""
6
+ steps:
7
+ - name: git_tui
8
+ engine: python
9
+ code: |
10
+ import os
11
+ import sys
12
+ import tty
13
+ import termios
14
+ import select
15
+ import subprocess
16
+ import textwrap
17
+
18
+ if not sys.stdin.isatty():
19
+ context['output'] = "Git TUI requires an interactive terminal."
20
+ else:
21
+ repo_path = context.get('path', '').strip() or os.getcwd()
22
+
23
+ def run_git(*args, cwd=None):
24
+ """Run a git command and return (returncode, stdout, stderr)."""
25
+ try:
26
+ r = subprocess.run(
27
+ ['git'] + list(args),
28
+ cwd=cwd or repo_path,
29
+ capture_output=True, text=True, timeout=15
30
+ )
31
+ return r.returncode, r.stdout, r.stderr
32
+ except Exception as e:
33
+ return 1, '', str(e)
34
+
35
+ # Check if we're in a git repo
36
+ rc, _, _ = run_git('rev-parse', '--is-inside-work-tree')
37
+ if rc != 0:
38
+ context['output'] = "Not a git repository: " + repo_path
39
+ else:
40
+ # Get repo root
41
+ _, root, _ = run_git('rev-parse', '--show-toplevel')
42
+ repo_root = root.strip()
43
+
44
+ def get_size():
45
+ try:
46
+ s = os.get_terminal_size()
47
+ return s.columns, s.lines
48
+ except:
49
+ return 80, 24
50
+
51
+ # ========== Data loading ==========
52
+ def load_status():
53
+ """Load git status into structured data."""
54
+ _, out, _ = run_git('status', '--porcelain=v1', '-uall')
55
+ staged = []
56
+ modified = []
57
+ untracked = []
58
+ for line in out.splitlines():
59
+ if len(line) < 4:
60
+ continue
61
+ x = line[0] # index status
62
+ y = line[1] # worktree status
63
+ path = line[3:]
64
+ # Rename: old -> new
65
+ if ' -> ' in path:
66
+ path = path.split(' -> ')[-1]
67
+
68
+ entry = {'path': path, 'x': x, 'y': y}
69
+ if x in ('M', 'A', 'D', 'R', 'C'):
70
+ staged.append(entry)
71
+ if y in ('M', 'D'):
72
+ modified.append(entry)
73
+ if x == '?' and y == '?':
74
+ untracked.append(entry)
75
+ return staged, modified, untracked
76
+
77
+ def load_log(n=30):
78
+ """Load recent commits."""
79
+ _, out, _ = run_git('log', '--oneline', '--decorate', '-n', str(n),
80
+ '--format=%h\t%s\t%an\t%ar\t%D')
81
+ commits = []
82
+ for line in out.splitlines():
83
+ parts = line.split('\t')
84
+ if len(parts) >= 4:
85
+ commits.append({
86
+ 'hash': parts[0],
87
+ 'subject': parts[1],
88
+ 'author': parts[2],
89
+ 'date': parts[3],
90
+ 'refs': parts[4] if len(parts) > 4 else ''
91
+ })
92
+ return commits
93
+
94
+ def load_branches():
95
+ """Load branch list."""
96
+ _, out, _ = run_git('branch', '-a', '--format=%(refname:short)\t%(objectname:short)\t%(HEAD)\t%(upstream:short)\t%(subject)')
97
+ branches = []
98
+ for line in out.splitlines():
99
+ parts = line.split('\t')
100
+ if len(parts) >= 3:
101
+ branches.append({
102
+ 'name': parts[0],
103
+ 'hash': parts[1] if len(parts) > 1 else '',
104
+ 'current': parts[2] == '*' if len(parts) > 2 else False,
105
+ 'upstream': parts[3] if len(parts) > 3 else '',
106
+ 'subject': parts[4] if len(parts) > 4 else ''
107
+ })
108
+ return branches
109
+
110
+ def load_diff(path=None, staged=False):
111
+ """Load diff output."""
112
+ args = ['diff', '--color=never']
113
+ if staged:
114
+ args.append('--cached')
115
+ if path:
116
+ args.extend(['--', path])
117
+ _, out, _ = run_git(*args)
118
+ return out.splitlines()
119
+
120
+ def load_stash():
121
+ """Load stash list."""
122
+ _, out, _ = run_git('stash', 'list')
123
+ return out.splitlines()
124
+
125
+ # ========== State ==========
126
+ TABS = ['Status', 'Log', 'Branches', 'Stash']
127
+
128
+ class St:
129
+ tab = 0
130
+ sel = 0
131
+ scroll = 0
132
+ # Status
133
+ staged = []
134
+ modified = []
135
+ untracked = []
136
+ all_files = [] # flat list for navigation
137
+ # Log
138
+ commits = []
139
+ # Branches
140
+ branches = []
141
+ # Stash
142
+ stashes = []
143
+ # Modes
144
+ mode = 'list' # list, diff, commit_msg, commit_detail
145
+ diff_lines = []
146
+ diff_scroll = 0
147
+ msg = ''
148
+ msg_color = '33'
149
+ # Commit input
150
+ commit_buf = ''
151
+ # Branch info
152
+ branch_name = ''
153
+ ahead_behind = ''
154
+
155
+ st = St()
156
+
157
+ def refresh_status():
158
+ st.staged, st.modified, st.untracked = load_status()
159
+ # Build flat file list with section markers
160
+ st.all_files = []
161
+ if st.staged:
162
+ st.all_files.append({'type': 'header', 'label': 'Staged Changes'})
163
+ for f in st.staged:
164
+ st.all_files.append({'type': 'staged', 'entry': f})
165
+ if st.modified:
166
+ st.all_files.append({'type': 'header', 'label': 'Modified (unstaged)'})
167
+ for f in st.modified:
168
+ st.all_files.append({'type': 'modified', 'entry': f})
169
+ if st.untracked:
170
+ st.all_files.append({'type': 'header', 'label': 'Untracked'})
171
+ for f in st.untracked:
172
+ st.all_files.append({'type': 'untracked', 'entry': f})
173
+ # Clamp selection
174
+ if st.all_files:
175
+ st.sel = min(st.sel, len(st.all_files) - 1)
176
+ # Skip headers
177
+ while st.sel < len(st.all_files) and st.all_files[st.sel]['type'] == 'header':
178
+ st.sel += 1
179
+ if st.sel >= len(st.all_files):
180
+ st.sel = max(0, len(st.all_files) - 1)
181
+ else:
182
+ st.sel = 0
183
+
184
+ def refresh_branch_info():
185
+ _, name, _ = run_git('branch', '--show-current')
186
+ st.branch_name = name.strip()
187
+ _, ab, _ = run_git('rev-list', '--left-right', '--count', 'HEAD...@{upstream}')
188
+ parts = ab.strip().split()
189
+ if len(parts) == 2:
190
+ ahead, behind = parts
191
+ info = []
192
+ if int(ahead) > 0:
193
+ info.append('+' + ahead)
194
+ if int(behind) > 0:
195
+ info.append('-' + behind)
196
+ st.ahead_behind = ' '.join(info)
197
+ else:
198
+ st.ahead_behind = ''
199
+
200
+ def refresh_all():
201
+ refresh_status()
202
+ refresh_branch_info()
203
+
204
+ def refresh_tab():
205
+ if st.tab == 0:
206
+ refresh_status()
207
+ elif st.tab == 1:
208
+ st.commits = load_log()
209
+ elif st.tab == 2:
210
+ st.branches = load_branches()
211
+ elif st.tab == 3:
212
+ st.stashes = load_stash()
213
+
214
+ # ========== Rendering ==========
215
+ def render():
216
+ width, height = get_size()
217
+ out = []
218
+
219
+ # Header
220
+ branch_str = st.branch_name
221
+ if st.ahead_behind:
222
+ branch_str += ' [' + st.ahead_behind + ']'
223
+ hdr = ' GIT: ' + os.path.basename(repo_root) + ' (' + branch_str + ') '
224
+ out.append('\033[1;1H\033[K\033[7;1m' + hdr.ljust(width) + '\033[0m')
225
+
226
+ # Tabs
227
+ tab_str = ''
228
+ for i, t in enumerate(TABS):
229
+ if i == st.tab:
230
+ tab_str += '\033[1;7m [' + t + '] \033[0m'
231
+ else:
232
+ tab_str += ' \033[90m' + t + '\033[0m '
233
+ out.append('\033[2;1H\033[K ' + tab_str)
234
+ out.append('\033[3;1H\033[K\033[90m' + ('-' * width) + '\033[0m')
235
+
236
+ if st.mode == 'commit_msg':
237
+ render_commit_input(out, width, height)
238
+ elif st.mode == 'diff':
239
+ render_diff(out, width, height)
240
+ elif st.mode == 'commit_detail':
241
+ render_commit_detail(out, width, height)
242
+ elif st.tab == 0:
243
+ render_status(out, width, height)
244
+ elif st.tab == 1:
245
+ render_log(out, width, height)
246
+ elif st.tab == 2:
247
+ render_branch_list(out, width, height)
248
+ elif st.tab == 3:
249
+ render_stash_list(out, width, height)
250
+
251
+ # Status message
252
+ out.append('\033[' + str(height-2) + ';1H\033[K\033[90m' + ('-' * width) + '\033[0m')
253
+ out.append('\033[' + str(height-1) + ';1H\033[K')
254
+ if st.msg:
255
+ out.append(' \033[' + st.msg_color + ';1m' + st.msg[:width-2] + '\033[0m')
256
+
257
+ # Footer
258
+ if st.mode == 'diff':
259
+ foot = ' [b] Back [j/k] Scroll [Space] PageDn [q] Quit '
260
+ elif st.mode == 'commit_msg':
261
+ foot = ' Type message, Enter to commit, Esc to cancel '
262
+ elif st.mode == 'commit_detail':
263
+ foot = ' [b] Back [j/k] Scroll [q] Quit '
264
+ elif st.tab == 0:
265
+ foot = ' [j/k] Nav [Enter] Diff [s] Stage/Unstage [a] StageAll [u] UnstageAll [c] Commit [Tab] Switch [q] Quit '
266
+ elif st.tab == 1:
267
+ foot = ' [j/k] Nav [Enter] Detail [Tab] Switch [q] Quit '
268
+ elif st.tab == 2:
269
+ foot = ' [j/k] Nav [Enter] Checkout [Tab] Switch [q] Quit '
270
+ elif st.tab == 3:
271
+ foot = ' [j/k] Nav [p] Pop [a] Apply [d] Drop [Tab] Switch [q] Quit '
272
+ else:
273
+ foot = ' [Tab] Switch [q] Quit '
274
+ out.append('\033[' + str(height) + ';1H\033[K\033[7m' + foot[:width].ljust(width) + '\033[0m')
275
+
276
+ sys.stdout.write(''.join(out))
277
+ sys.stdout.flush()
278
+
279
+ def render_status(out, width, height):
280
+ vis = height - 7
281
+ if not st.all_files:
282
+ out.append('\033[5;4H\033[32mWorking tree clean.\033[0m')
283
+ for i in range(1, vis):
284
+ out.append('\033[' + str(5+i) + ';1H\033[K')
285
+ return
286
+
287
+ for i in range(vis):
288
+ row = 4 + i
289
+ out.append('\033[' + str(row) + ';1H\033[K')
290
+ idx = st.scroll + i
291
+ if idx >= len(st.all_files):
292
+ continue
293
+
294
+ item = st.all_files[idx]
295
+ if item['type'] == 'header':
296
+ out.append(' \033[1;4m' + item['label'] + '\033[0m')
297
+ else:
298
+ e = item['entry']
299
+ if item['type'] == 'staged':
300
+ icon = '\033[1;32m+ \033[0m'
301
+ color = '32'
302
+ elif item['type'] == 'modified':
303
+ icon = '\033[1;33m~ \033[0m'
304
+ color = '33'
305
+ else:
306
+ icon = '\033[1;90m? \033[0m'
307
+ color = '90'
308
+
309
+ status_char = e['x'] + e['y']
310
+ line = icon + '\033[' + color + 'm' + e['path'] + '\033[0m \033[90m[' + status_char.strip() + ']\033[0m'
311
+
312
+ if idx == st.sel:
313
+ out.append('\033[7m ' + icon + e['path'] + ' [' + status_char.strip() + '] \033[0m')
314
+ else:
315
+ out.append(' ' + line)
316
+
317
+ def render_log(out, width, height):
318
+ vis = height - 7
319
+ if not st.commits:
320
+ out.append('\033[5;4H\033[90mNo commits.\033[0m')
321
+ return
322
+
323
+ hash_w = 8
324
+ date_w = 14
325
+ auth_w = 16
326
+ subj_w = width - hash_w - date_w - auth_w - 8
327
+
328
+ for i in range(vis):
329
+ row = 4 + i
330
+ out.append('\033[' + str(row) + ';1H\033[K')
331
+ idx = st.scroll + i
332
+ if idx >= len(st.commits):
333
+ continue
334
+
335
+ c = st.commits[idx]
336
+ refs = ''
337
+ if c['refs']:
338
+ refs = ' \033[1;33m(' + c['refs'][:20] + ')\033[0m'
339
+
340
+ line = ' \033[36m' + c['hash'][:7].ljust(hash_w) + '\033[0m'
341
+ line += '\033[90m' + c['date'][:date_w].ljust(date_w) + '\033[0m '
342
+ line += c['author'][:auth_w].ljust(auth_w) + ' '
343
+ line += c['subject'][:subj_w] + refs
344
+
345
+ if idx == st.sel:
346
+ # Selected - use reverse video with plain text
347
+ plain = c['hash'][:7].ljust(hash_w) + c['date'][:date_w].ljust(date_w) + ' ' + c['author'][:auth_w].ljust(auth_w) + ' ' + c['subject'][:subj_w]
348
+ if c['refs']:
349
+ plain += ' (' + c['refs'][:20] + ')'
350
+ out.append('\033[7m ' + plain[:width-2] + ' \033[0m')
351
+ else:
352
+ out.append(line)
353
+
354
+ def render_branch_list(out, width, height):
355
+ vis = height - 7
356
+ if not st.branches:
357
+ out.append('\033[5;4H\033[90mNo branches.\033[0m')
358
+ return
359
+
360
+ for i in range(vis):
361
+ row = 4 + i
362
+ out.append('\033[' + str(row) + ';1H\033[K')
363
+ idx = st.scroll + i
364
+ if idx >= len(st.branches):
365
+ continue
366
+
367
+ br = st.branches[idx]
368
+ cur = '\033[1;32m* \033[0m' if br['current'] else ' '
369
+ name_color = '32' if br['current'] else ('31' if br['name'].startswith('origin/') else '0')
370
+ up = ''
371
+ if br['upstream']:
372
+ up = ' \033[90m-> ' + br['upstream'] + '\033[0m'
373
+
374
+ if idx == st.sel:
375
+ plain = ('* ' if br['current'] else ' ') + br['name'] + ' ' + br['hash'] + ' ' + br['subject'][:40]
376
+ out.append('\033[7m' + plain[:width] + '\033[0m')
377
+ else:
378
+ out.append(cur + '\033[' + name_color + 'm' + br['name'] + '\033[0m \033[90m' + br['hash'] + '\033[0m ' + br['subject'][:40] + up)
379
+
380
+ def render_stash_list(out, width, height):
381
+ vis = height - 7
382
+ if not st.stashes:
383
+ out.append('\033[5;4H\033[90mNo stashes.\033[0m')
384
+ return
385
+
386
+ for i in range(vis):
387
+ row = 4 + i
388
+ out.append('\033[' + str(row) + ';1H\033[K')
389
+ idx = st.scroll + i
390
+ if idx >= len(st.stashes):
391
+ continue
392
+
393
+ line = st.stashes[idx]
394
+ if idx == st.sel:
395
+ out.append('\033[7m ' + line[:width-2] + ' \033[0m')
396
+ else:
397
+ out.append(' ' + line[:width-2])
398
+
399
+ def render_diff(out, width, height):
400
+ vis = height - 7
401
+ for i in range(vis):
402
+ row = 4 + i
403
+ out.append('\033[' + str(row) + ';1H\033[K')
404
+ idx = st.diff_scroll + i
405
+ if idx >= len(st.diff_lines):
406
+ continue
407
+ line = st.diff_lines[idx]
408
+ if line.startswith('+') and not line.startswith('+++'):
409
+ out.append('\033[32m' + line[:width-1] + '\033[0m')
410
+ elif line.startswith('-') and not line.startswith('---'):
411
+ out.append('\033[31m' + line[:width-1] + '\033[0m')
412
+ elif line.startswith('@@'):
413
+ out.append('\033[36m' + line[:width-1] + '\033[0m')
414
+ elif line.startswith('diff ') or line.startswith('index '):
415
+ out.append('\033[1m' + line[:width-1] + '\033[0m')
416
+ else:
417
+ out.append(line[:width-1])
418
+
419
+ if not st.diff_lines:
420
+ out.append('\033[5;4H\033[90mNo diff to show.\033[0m')
421
+
422
+ def render_commit_input(out, width, height):
423
+ out.append('\033[5;3H\033[1mCommit Message:\033[0m')
424
+ out.append('\033[7;3H\033[K' + st.commit_buf + '\033[7m \033[0m')
425
+ # Show staged files
426
+ out.append('\033[9;3H\033[90mStaged files:\033[0m')
427
+ for i, f in enumerate(st.staged[:height-13]):
428
+ out.append('\033[' + str(10+i) + ';5H\033[K\033[32m+ ' + f['path'] + '\033[0m')
429
+ for i in range(10 + len(st.staged[:height-13]), height - 2):
430
+ out.append('\033[' + str(i) + ';1H\033[K')
431
+
432
+ def render_commit_detail(out, width, height):
433
+ vis = height - 7
434
+ for i in range(vis):
435
+ row = 4 + i
436
+ out.append('\033[' + str(row) + ';1H\033[K')
437
+ idx = st.diff_scroll + i
438
+ if idx >= len(st.diff_lines):
439
+ continue
440
+ line = st.diff_lines[idx]
441
+ if line.startswith('+') and not line.startswith('+++'):
442
+ out.append('\033[32m' + line[:width-1] + '\033[0m')
443
+ elif line.startswith('-') and not line.startswith('---'):
444
+ out.append('\033[31m' + line[:width-1] + '\033[0m')
445
+ elif line.startswith('@@'):
446
+ out.append('\033[36m' + line[:width-1] + '\033[0m')
447
+ else:
448
+ out.append(line[:width-1])
449
+
450
+ # ========== Actions ==========
451
+ def stage_file(entry):
452
+ rc, _, err = run_git('add', '--', entry['path'])
453
+ if rc == 0:
454
+ st.msg = 'Staged: ' + entry['path']
455
+ st.msg_color = '32'
456
+ else:
457
+ st.msg = 'Stage failed: ' + err[:60]
458
+ st.msg_color = '31'
459
+ refresh_status()
460
+
461
+ def unstage_file(entry):
462
+ rc, _, err = run_git('restore', '--staged', '--', entry['path'])
463
+ if rc == 0:
464
+ st.msg = 'Unstaged: ' + entry['path']
465
+ st.msg_color = '33'
466
+ else:
467
+ st.msg = 'Unstage failed: ' + err[:60]
468
+ st.msg_color = '31'
469
+ refresh_status()
470
+
471
+ def stage_all():
472
+ rc, _, err = run_git('add', '-A')
473
+ if rc == 0:
474
+ st.msg = 'Staged all changes'
475
+ st.msg_color = '32'
476
+ else:
477
+ st.msg = 'Stage all failed: ' + err[:60]
478
+ st.msg_color = '31'
479
+ refresh_status()
480
+
481
+ def unstage_all():
482
+ rc, _, err = run_git('restore', '--staged', '.')
483
+ if rc == 0:
484
+ st.msg = 'Unstaged all'
485
+ st.msg_color = '33'
486
+ else:
487
+ st.msg = 'Unstage failed: ' + err[:60]
488
+ st.msg_color = '31'
489
+ refresh_status()
490
+
491
+ def do_commit(message):
492
+ if not message.strip():
493
+ st.msg = 'Empty commit message, cancelled'
494
+ st.msg_color = '33'
495
+ return
496
+ rc, out, err = run_git('commit', '-m', message)
497
+ if rc == 0:
498
+ st.msg = 'Committed: ' + message[:50]
499
+ st.msg_color = '32'
500
+ else:
501
+ st.msg = 'Commit failed: ' + (err or out)[:60]
502
+ st.msg_color = '31'
503
+ refresh_status()
504
+
505
+ def checkout_branch(name):
506
+ # Don't checkout remote tracking refs directly
507
+ local_name = name
508
+ if name.startswith('origin/'):
509
+ local_name = name[7:]
510
+ rc, _, err = run_git('checkout', local_name)
511
+ if rc == 0:
512
+ st.msg = 'Switched to: ' + local_name
513
+ st.msg_color = '32'
514
+ refresh_branch_info()
515
+ else:
516
+ st.msg = 'Checkout failed: ' + err[:60]
517
+ st.msg_color = '31'
518
+ st.branches = load_branches()
519
+
520
+ def stash_pop(idx=0):
521
+ rc, _, err = run_git('stash', 'pop', 'stash@{' + str(idx) + '}')
522
+ if rc == 0:
523
+ st.msg = 'Popped stash@{' + str(idx) + '}'
524
+ st.msg_color = '32'
525
+ else:
526
+ st.msg = 'Pop failed: ' + err[:60]
527
+ st.msg_color = '31'
528
+ st.stashes = load_stash()
529
+ refresh_status()
530
+
531
+ def stash_apply(idx=0):
532
+ rc, _, err = run_git('stash', 'apply', 'stash@{' + str(idx) + '}')
533
+ if rc == 0:
534
+ st.msg = 'Applied stash@{' + str(idx) + '}'
535
+ st.msg_color = '32'
536
+ else:
537
+ st.msg = 'Apply failed: ' + err[:60]
538
+ st.msg_color = '31'
539
+ refresh_status()
540
+
541
+ def stash_drop(idx=0):
542
+ rc, _, err = run_git('stash', 'drop', 'stash@{' + str(idx) + '}')
543
+ if rc == 0:
544
+ st.msg = 'Dropped stash@{' + str(idx) + '}'
545
+ st.msg_color = '33'
546
+ else:
547
+ st.msg = 'Drop failed: ' + err[:60]
548
+ st.msg_color = '31'
549
+ st.stashes = load_stash()
550
+
551
+ # ========== Input ==========
552
+ def list_len():
553
+ if st.tab == 0:
554
+ return len(st.all_files)
555
+ elif st.tab == 1:
556
+ return len(st.commits)
557
+ elif st.tab == 2:
558
+ return len(st.branches)
559
+ elif st.tab == 3:
560
+ return len(st.stashes)
561
+ return 0
562
+
563
+ def move_up():
564
+ if st.sel > 0:
565
+ st.sel -= 1
566
+ # Skip headers in status view
567
+ if st.tab == 0 and st.all_files and st.all_files[st.sel]['type'] == 'header':
568
+ if st.sel > 0:
569
+ st.sel -= 1
570
+ _, h = get_size()
571
+ vis = max(1, h - 7)
572
+ if st.sel < st.scroll:
573
+ st.scroll = st.sel
574
+
575
+ def move_down():
576
+ mx = list_len() - 1
577
+ if st.sel < mx:
578
+ st.sel += 1
579
+ if st.tab == 0 and st.sel < len(st.all_files) and st.all_files[st.sel]['type'] == 'header':
580
+ if st.sel < mx:
581
+ st.sel += 1
582
+ _, h = get_size()
583
+ vis = max(1, h - 7)
584
+ if st.sel >= st.scroll + vis:
585
+ st.scroll = st.sel - vis + 1
586
+
587
+ def handle_input(c):
588
+ # Commit message mode
589
+ if st.mode == 'commit_msg':
590
+ if c == '\x1b':
591
+ st.mode = 'list'
592
+ st.commit_buf = ''
593
+ st.msg = 'Commit cancelled'
594
+ st.msg_color = '33'
595
+ elif c in ('\r', '\n'):
596
+ do_commit(st.commit_buf)
597
+ st.mode = 'list'
598
+ st.commit_buf = ''
599
+ elif c == '\x7f' or c == '\b':
600
+ st.commit_buf = st.commit_buf[:-1]
601
+ elif c == '\x03':
602
+ st.mode = 'list'
603
+ st.commit_buf = ''
604
+ elif c.isprintable():
605
+ st.commit_buf += c
606
+ return True
607
+
608
+ # Diff / detail scroll mode
609
+ if st.mode in ('diff', 'commit_detail'):
610
+ if c == 'q' or c == '\x03':
611
+ return False
612
+ if c == 'b' or c == '\x1b':
613
+ if c == '\x1b':
614
+ if select.select([fd], [], [], 0.05)[0]:
615
+ os.read(fd, 1) # consume [
616
+ c3 = os.read(fd, 1).decode('latin-1')
617
+ if c3 == 'A':
618
+ st.diff_scroll = max(0, st.diff_scroll - 1)
619
+ elif c3 == 'B':
620
+ st.diff_scroll += 1
621
+ return True
622
+ st.mode = 'list'
623
+ st.diff_scroll = 0
624
+ sys.stdout.write('\033[2J')
625
+ return True
626
+ if c == 'j':
627
+ st.diff_scroll += 1
628
+ elif c == 'k':
629
+ st.diff_scroll = max(0, st.diff_scroll - 1)
630
+ elif c == ' ':
631
+ _, h = get_size()
632
+ st.diff_scroll += h - 7
633
+ elif c == 'b':
634
+ _, h = get_size()
635
+ st.diff_scroll = max(0, st.diff_scroll - (h - 7))
636
+ return True
637
+
638
+ # Normal list mode
639
+ if c == 'q' or c == '\x03':
640
+ return False
641
+
642
+ if c == '\x1b':
643
+ if select.select([fd], [], [], 0.05)[0]:
644
+ c2 = os.read(fd, 1).decode('latin-1')
645
+ if c2 == '[':
646
+ c3 = os.read(fd, 1).decode('latin-1')
647
+ if c3 == 'A':
648
+ move_up()
649
+ elif c3 == 'B':
650
+ move_down()
651
+ elif c3 == 'Z':
652
+ # Shift+Tab
653
+ st.tab = (st.tab - 1) % len(TABS)
654
+ st.sel = 0
655
+ st.scroll = 0
656
+ refresh_tab()
657
+ st.msg = ''
658
+ sys.stdout.write('\033[2J')
659
+ return True
660
+
661
+ if c == '\t':
662
+ st.tab = (st.tab + 1) % len(TABS)
663
+ st.sel = 0
664
+ st.scroll = 0
665
+ refresh_tab()
666
+ st.msg = ''
667
+ sys.stdout.write('\033[2J')
668
+ elif c == 'j':
669
+ move_down()
670
+ elif c == 'k':
671
+ move_up()
672
+ elif c == 'g':
673
+ st.sel = 0
674
+ st.scroll = 0
675
+ elif c == 'G':
676
+ st.sel = max(0, list_len() - 1)
677
+ _, h = get_size()
678
+ vis = max(1, h - 7)
679
+ st.scroll = max(0, st.sel - vis + 1)
680
+ elif c in ('\r', '\n'):
681
+ handle_enter()
682
+ elif c == 's' and st.tab == 0:
683
+ handle_stage_toggle()
684
+ elif c == 'a' and st.tab == 0:
685
+ stage_all()
686
+ elif c == 'u' and st.tab == 0:
687
+ unstage_all()
688
+ elif c == 'c' and st.tab == 0:
689
+ if st.staged:
690
+ st.mode = 'commit_msg'
691
+ st.commit_buf = ''
692
+ st.msg = ''
693
+ sys.stdout.write('\033[2J')
694
+ else:
695
+ st.msg = 'Nothing staged to commit'
696
+ st.msg_color = '33'
697
+ elif c == 'r' and st.tab == 0:
698
+ # Refresh
699
+ refresh_all()
700
+ st.msg = 'Refreshed'
701
+ st.msg_color = '36'
702
+ elif c == 'p' and st.tab == 3:
703
+ if st.stashes:
704
+ stash_pop(st.sel)
705
+ elif c == 'a' and st.tab == 3:
706
+ if st.stashes:
707
+ stash_apply(st.sel)
708
+ elif c == 'd' and st.tab == 3:
709
+ if st.stashes:
710
+ stash_drop(st.sel)
711
+
712
+ return True
713
+
714
+ def handle_enter():
715
+ if st.tab == 0:
716
+ # Show diff for selected file
717
+ if st.all_files and st.sel < len(st.all_files):
718
+ item = st.all_files[st.sel]
719
+ if item['type'] == 'header':
720
+ return
721
+ e = item['entry']
722
+ is_staged = item['type'] == 'staged'
723
+ st.diff_lines = load_diff(e['path'], staged=is_staged)
724
+ if not st.diff_lines:
725
+ # Try unstaged diff
726
+ st.diff_lines = load_diff(e['path'], staged=False)
727
+ if not st.diff_lines:
728
+ st.msg = 'No diff for: ' + e['path']
729
+ st.msg_color = '33'
730
+ return
731
+ st.mode = 'diff'
732
+ st.diff_scroll = 0
733
+ sys.stdout.write('\033[2J')
734
+ elif st.tab == 1:
735
+ # Show commit detail
736
+ if st.commits and st.sel < len(st.commits):
737
+ c = st.commits[st.sel]
738
+ _, detail, _ = run_git('show', '--stat', '--format=fuller', c['hash'])
739
+ st.diff_lines = detail.splitlines()
740
+ st.mode = 'commit_detail'
741
+ st.diff_scroll = 0
742
+ sys.stdout.write('\033[2J')
743
+ elif st.tab == 2:
744
+ # Checkout branch
745
+ if st.branches and st.sel < len(st.branches):
746
+ br = st.branches[st.sel]
747
+ if not br['current']:
748
+ checkout_branch(br['name'])
749
+ elif st.tab == 3:
750
+ # Show stash diff
751
+ if st.stashes and st.sel < len(st.stashes):
752
+ _, detail, _ = run_git('stash', 'show', '-p', 'stash@{' + str(st.sel) + '}')
753
+ st.diff_lines = detail.splitlines()
754
+ st.mode = 'diff'
755
+ st.diff_scroll = 0
756
+ sys.stdout.write('\033[2J')
757
+
758
+ def handle_stage_toggle():
759
+ if not st.all_files or st.sel >= len(st.all_files):
760
+ return
761
+ item = st.all_files[st.sel]
762
+ if item['type'] == 'header':
763
+ return
764
+ e = item['entry']
765
+ if item['type'] == 'staged':
766
+ unstage_file(e)
767
+ else:
768
+ stage_file(e)
769
+
770
+ # ========== Main Loop ==========
771
+ refresh_all()
772
+ refresh_tab()
773
+
774
+ fd = sys.stdin.fileno()
775
+ old_settings = termios.tcgetattr(fd)
776
+
777
+ try:
778
+ tty.setcbreak(fd)
779
+ sys.stdout.write('\033[?25l')
780
+ sys.stdout.write('\033[2J')
781
+ render()
782
+
783
+ while True:
784
+ c = os.read(fd, 1).decode('latin-1')
785
+ if not handle_input(c):
786
+ break
787
+ render()
788
+
789
+ finally:
790
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
791
+ sys.stdout.write('\033[?25h')
792
+ sys.stdout.write('\033[2J\033[H')
793
+ sys.stdout.flush()
794
+
795
+ context['output'] = 'Git TUI closed.'