kiwi-code 0.0.30__tar.gz → 0.0.32__tar.gz

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 (52) hide show
  1. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/PKG-INFO +1 -1
  2. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/pyproject.toml +1 -1
  3. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/cli.py +85 -11
  4. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/client.py +8 -1
  5. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/dashboard.py +304 -53
  6. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/uv.lock +1 -1
  7. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.github/workflows/publish.yml +0 -0
  8. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.github/workflows/test.yml +0 -0
  9. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.gitignore +0 -0
  10. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/.python-version +0 -0
  11. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/CLAUDE.md +0 -0
  12. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/Makefile +0 -0
  13. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/README.md +0 -0
  14. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/__init__.py +0 -0
  15. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/auth.py +0 -0
  16. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/commands.py +0 -0
  17. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/logger.py +0 -0
  18. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/models.py +0 -0
  19. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/runtime_manager.py +0 -0
  20. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_cli/server.py +0 -0
  21. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/__init__.py +0 -0
  22. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/__main__.py +0 -0
  23. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/main.py +0 -0
  24. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/snake_game/.gitignore +0 -0
  25. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_runtime/snake_game/requirements.txt +0 -0
  26. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/__init__.py +0 -0
  27. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/inline_file_picker.py +0 -0
  28. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/main.py +0 -0
  29. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/random_words.py +0 -0
  30. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/runtime_agent.py +0 -0
  31. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/__init__.py +0 -0
  32. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/attach_content.py +0 -0
  33. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/command_result.py +0 -0
  34. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/file_browser.py +0 -0
  35. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/help.py +0 -0
  36. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/id_picker.py +0 -0
  37. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/login.py +0 -0
  38. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
  39. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/runtime_logs.py +0 -0
  40. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/screens/slash_picker.py +0 -0
  41. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/slash_commands.py +0 -0
  42. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/status_words.py +0 -0
  43. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/src/kiwi_tui/widgets.py +0 -0
  44. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/test_hello.py +0 -0
  45. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/__init__.py +0 -0
  46. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/conftest.py +0 -0
  47. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_cli_help.py +0 -0
  48. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_imports.py +0 -0
  49. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_reexec_kiwi.py +0 -0
  50. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_runtime_log_trimming.py +0 -0
  51. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_tokens.py +0 -0
  52. {kiwi_code-0.0.30 → kiwi_code-0.0.32}/tests/test_tui_headless.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kiwi-code
3
- Version: 0.0.30
3
+ Version: 0.0.32
4
4
  Summary: A textual-based terminal user interface application
5
5
  Project-URL: Homepage, https://meetkiwi.ai
6
6
  Project-URL: Repository, https://github.com/jetoslabs/kiwi-code
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kiwi-code"
3
- version = "0.0.30"
3
+ version = "0.0.32"
4
4
  description = "A textual-based terminal user interface application"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  requires-python = ">=3.11,<4.0"
@@ -1,6 +1,6 @@
1
1
  """Typer CLI for Autobots — thin wrapper over commands.py."""
2
2
 
3
- from typing import Optional
3
+ from typing import Optional, Callable
4
4
 
5
5
  from loguru import logger
6
6
  logger.remove() # Suppress loguru console output in CLI mode
@@ -55,18 +55,92 @@ def _backend_url() -> str:
55
55
  return AppConfig().backend_url
56
56
 
57
57
 
58
+ def _http_status_from_lines(lines: list[str]) -> int | None:
59
+ """Extract an HTTP status code from commands.py output lines.
60
+
61
+ commands.py returns errors in the form: ["Error: HTTP <code>"]
62
+ """
63
+ if not lines:
64
+ return None
65
+ if len(lines) != 1:
66
+ return None
67
+ s = lines[0].strip()
68
+ prefix = "Error: HTTP "
69
+ if not s.startswith(prefix):
70
+ return None
71
+ try:
72
+ return int(s[len(prefix):].strip())
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def _force_refresh_tokens(tm: TokenManager) -> bool:
78
+ """Force refresh tokens once and persist them."""
79
+ from .client import AutobotsClientWrapper
80
+
81
+ with tm.file_lock():
82
+ tokens = tm.load_tokens()
83
+ if not tokens:
84
+ return False
85
+ refresh = getattr(tokens, "refresh_token", None)
86
+ if not refresh:
87
+ return False
88
+
89
+ wrapper = AutobotsClientWrapper(
90
+ base_url=_backend_url(),
91
+ access_token=getattr(tokens, "access_token", None),
92
+ )
93
+ ok, new_tokens, _msg = wrapper.refresh_token(str(refresh))
94
+ if ok and new_tokens:
95
+ tm.save_tokens(new_tokens)
96
+ return True
97
+ return False
98
+
99
+
58
100
  def _get_client() -> AuthenticatedClient:
101
+ """Get an authenticated OpenAPI client.
102
+
103
+ If the access token is expired but a refresh token exists, attempt a refresh
104
+ once. This improves CLI reliability for long-lived sessions.
105
+ """
59
106
  tm = TokenManager()
60
- if not tm.is_authenticated():
107
+ tokens = tm.load_tokens()
108
+ if not tokens:
109
+ typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
110
+ raise typer.Exit(code=1)
111
+
112
+ try:
113
+ if tokens.is_expired() and getattr(tokens, "refresh_token", None):
114
+ _force_refresh_tokens(tm)
115
+ tokens = tm.load_tokens() or tokens
116
+ except Exception:
117
+ pass
118
+
119
+ access = getattr(tokens, "access_token", None)
120
+ if not access:
61
121
  typer.echo("Not authenticated. Run `kiwi login` first.", err=True)
62
122
  raise typer.Exit(code=1)
123
+
63
124
  return AuthenticatedClient(
64
125
  base_url=_backend_url(),
65
- token=tm.get_access_token(),
126
+ token=str(access),
66
127
  raise_on_unexpected_status=False,
67
128
  )
68
129
 
69
130
 
131
+ def _run_with_refresh_retry(run: Callable[[AuthenticatedClient], list[str]]) -> list[str]:
132
+ """Run a CLI command with one forced refresh retry on 401/403."""
133
+ tm = TokenManager()
134
+ lines = run(_get_client())
135
+ code = _http_status_from_lines(lines)
136
+ if code in (401, 403):
137
+ try:
138
+ if _force_refresh_tokens(tm):
139
+ lines = run(_get_client())
140
+ except Exception:
141
+ pass
142
+ return lines
143
+
70
144
  def _print(lines: list[str]) -> None:
71
145
  for line in lines:
72
146
  typer.echo(line)
@@ -81,12 +155,12 @@ def actions_list_cmd(
81
155
  offset: int = typer.Option(0, help="Offset"),
82
156
  ):
83
157
  """List actions."""
84
- _print(commands.actions_list(_get_client(), name=name, limit=limit, offset=offset))
158
+ _print(_run_with_refresh_retry(lambda c: commands.actions_list(c, name=name, limit=limit, offset=offset)))
85
159
 
86
160
  @actions_app.command("get")
87
161
  def actions_get_cmd(id: str = typer.Argument(help="Action ID")):
88
162
  """Get action details."""
89
- _print(commands.actions_get(_get_client(), id=id))
163
+ _print(_run_with_refresh_retry(lambda c: commands.actions_get(c, id=id)))
90
164
 
91
165
 
92
166
  # -- runs -------------------------------------------------------------------
@@ -100,12 +174,12 @@ def runs_list_cmd(
100
174
  offset: int = typer.Option(0, help="Offset"),
101
175
  ):
102
176
  """List action runs (results)."""
103
- _print(commands.runs_list(_get_client(), action_id=action_id, action_name=action_name, status=status, limit=limit, offset=offset))
177
+ _print(_run_with_refresh_retry(lambda c: commands.runs_list(c, action_id=action_id, action_name=action_name, status=status, limit=limit, offset=offset)))
104
178
 
105
179
  @action_runs_app.command("get")
106
180
  def runs_get_cmd(id: str = typer.Argument(help="Action run ID")):
107
181
  """Get action run details."""
108
- _print(commands.runs_get(_get_client(), id=id))
182
+ _print(_run_with_refresh_retry(lambda c: commands.runs_get(c, id=id)))
109
183
 
110
184
 
111
185
  # -- graphs -----------------------------------------------------------------
@@ -117,12 +191,12 @@ def graphs_list_cmd(
117
191
  offset: int = typer.Option(0, help="Offset"),
118
192
  ):
119
193
  """List action graphs."""
120
- _print(commands.graphs_list(_get_client(), name=name, limit=limit, offset=offset))
194
+ _print(_run_with_refresh_retry(lambda c: commands.graphs_list(c, name=name, limit=limit, offset=offset)))
121
195
 
122
196
  @graphs_app.command("get")
123
197
  def graphs_get_cmd(id: str = typer.Argument(help="Action graph ID")):
124
198
  """Get action graph details."""
125
- _print(commands.graphs_get(_get_client(), id=id))
199
+ _print(_run_with_refresh_retry(lambda c: commands.graphs_get(c, id=id)))
126
200
 
127
201
 
128
202
  # -- graph-runs -------------------------------------------------------------
@@ -136,12 +210,12 @@ def graph_runs_list_cmd(
136
210
  offset: int = typer.Option(0, help="Offset"),
137
211
  ):
138
212
  """List action graph runs (results)."""
139
- _print(commands.graph_runs_list(_get_client(), action_graph_id=graph_id, action_graph_name=graph_name, status=status, limit=limit, offset=offset))
213
+ _print(_run_with_refresh_retry(lambda c: commands.graph_runs_list(c, action_graph_id=graph_id, action_graph_name=graph_name, status=status, limit=limit, offset=offset)))
140
214
 
141
215
  @graph_runs_app.command("get")
142
216
  def graph_runs_get_cmd(id: str = typer.Argument(help="Graph run ID")):
143
217
  """Get action graph run details."""
144
- _print(commands.graph_runs_get(_get_client(), id=id))
218
+ _print(_run_with_refresh_retry(lambda c: commands.graph_runs_get(c, id=id)))
145
219
 
146
220
 
147
221
  # -- runtime ----------------------------------------------------------------
@@ -183,7 +183,14 @@ class AutobotsClientWrapper:
183
183
  # even if `refresh_token` is provided as a query param.
184
184
  url = f"{self.base_url.rstrip('/')}/v1/auth/session/refresh"
185
185
  with httpx.Client(timeout=30) as client:
186
- resp = client.post(url, params={"refresh_token": refresh_token})
186
+ # Best-effort: send the (possibly expired) access token for anomaly tracking.
187
+ # The backend refresh endpoint must not *require* it.
188
+ headers: dict[str, str] = {}
189
+ if self.access_token:
190
+ headers["Authorization"] = f"Bearer {self.access_token}"
191
+ resp = client.post(
192
+ url, params={"refresh_token": refresh_token}, headers=headers
193
+ )
187
194
 
188
195
  if resp.status_code != 200:
189
196
  detail = (resp.text or "").strip()
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from typing import Callable
5
6
  from textual.app import ComposeResult
6
7
  from textual.binding import Binding
7
8
  from textual.screen import Screen
@@ -720,6 +721,36 @@ class DashboardScreen(Screen):
720
721
  def show(body: str, *, is_error: bool = False, title: str | None = None) -> None:
721
722
  self._show_command_result(title or command, body, is_error=is_error)
722
723
 
724
+ def toast(
725
+ message: str,
726
+ *,
727
+ severity: "SeverityLevel" = "information",
728
+ title: str = "",
729
+ timeout: float | None = 2.5,
730
+ ) -> None:
731
+ """Show a non-blocking toast notification.
732
+
733
+ Preferred for slash commands where the user doesn't need a full modal
734
+ (e.g. /new, /use, /continue).
735
+ """
736
+ try:
737
+ # Screen inherits Widget, which provides notify() (delegates to App.notify).
738
+ self.notify(
739
+ message,
740
+ title=title,
741
+ severity=severity,
742
+ timeout=timeout,
743
+ markup=False,
744
+ )
745
+ except Exception:
746
+ # If toast notifications fail for any reason, fall back to the modal so
747
+ # the user still gets feedback.
748
+ self._show_command_result(
749
+ title or command, message, is_error=severity == "error"
750
+ )
751
+
752
+
753
+
723
754
  # --- TUI-specific commands ---
724
755
 
725
756
  if cmd == "/upload":
@@ -870,58 +901,62 @@ class DashboardScreen(Screen):
870
901
  self.current_run_kind = None
871
902
  self._update_run_status_bar()
872
903
  self._clear_chat_messages()
873
- show(f"Starting new conversation with default action ({self.DEFAULT_ACTION_ID}).")
904
+ toast("Started a new conversation.", title="/new")
874
905
  return
875
906
  if cmd == "/use":
876
907
  if not args:
877
- show("Usage: /use <action_id>")
908
+ toast("Usage: /use <action_id>", title="/use", severity="warning")
878
909
  return
879
910
  action_id = args[0]
880
- api_client = self._get_api_client_for_command(command)
881
- if not api_client:
882
- return
883
911
  from kiwi_cli.commands import actions_get
884
- lines = await asyncio.to_thread(lambda: actions_get(api_client, id=action_id))
912
+ lines = await self._run_lines_with_refresh_retry(
913
+ command, lambda c: actions_get(c, id=action_id)
914
+ )
915
+ if lines is None:
916
+ return
885
917
  if lines and lines[0].startswith("Error"):
886
- show(f"Action not found: {action_id}", is_error=True)
918
+ toast(f"Action not found: {action_id}", title="/use", severity="error")
887
919
  return
888
920
  self.current_action_id = action_id
889
921
  self.current_run_id = None
890
922
  self.current_run_kind = None
891
923
  self._update_run_status_bar()
892
924
  self._clear_chat_messages()
893
- show("\n".join(["Switched to action:"] + lines))
925
+ toast(f"Switched to action {action_id}.", title="/use")
894
926
  return
895
927
 
896
928
  if cmd == "/continue":
897
929
  if not args:
898
- show("Usage: /continue <run_id>")
930
+ toast("Usage: /continue <run_id>", title="/continue", severity="warning")
899
931
  return
900
932
  run_id = args[0]
901
- api_client = self._get_api_client_for_command(command)
902
- if not api_client:
903
- return
904
933
  from kiwi_cli.commands import runs_get
905
- lines = await asyncio.to_thread(lambda: runs_get(api_client, id=run_id))
934
+ lines = await self._run_lines_with_refresh_retry(
935
+ command, lambda c: runs_get(c, id=run_id)
936
+ )
937
+ if lines is None:
938
+ return
906
939
  if lines and lines[0].startswith("Error"):
907
- show(f"Run not found: {run_id}", is_error=True)
940
+ toast(f"Run not found: {run_id}", title="/continue", severity="error")
908
941
  return
909
942
  self.current_run_id = run_id
910
943
  self.current_run_kind = "action"
911
944
  self._update_run_status_bar()
912
- show("\n".join(["Continuing run:"] + lines))
945
+ toast(f"Continuing run {run_id}...", title="/continue")
913
946
  await self._load_conversation_history_async(run_id)
914
947
  return
915
948
 
916
949
  if cmd == "/name":
917
950
  new_name = " ".join(args).strip()
918
951
  if not new_name:
919
- show("Usage: /name <new name>")
952
+ toast("Usage: /name <new name>", title="/name", severity="warning")
920
953
  return
921
954
  if not self.current_run_id:
922
- show(
923
- "Error: no run id yet. Start a conversation (send a message), or use /continue <run_id>, or pick one from /runs list.",
924
- is_error=True,
955
+ toast(
956
+ "No run id yet. Send a message to start a run, or use /continue <run_id> (or pick one from /runs list).",
957
+ title="/name",
958
+ severity="error",
959
+ timeout=4,
925
960
  )
926
961
  return
927
962
 
@@ -987,10 +1022,15 @@ class DashboardScreen(Screen):
987
1022
 
988
1023
  if status_code != 200:
989
1024
  details = err or ""
990
- show(
991
- f"Error: failed to rename run {run_id}. Tried: {', '.join(tried)}. HTTP {status_code}. {details}",
992
- is_error=True,
993
- )
1025
+ # Keep this as a toast (non-blocking). Truncate details to avoid huge toasts.
1026
+ details_short = (details or "").strip().replace("\n", " ")
1027
+ if len(details_short) > 160:
1028
+ details_short = details_short[:157] + "..."
1029
+ msg = f"Failed to rename run {run_id} (HTTP {status_code})."
1030
+ if details_short:
1031
+ msg += f" {details_short}"
1032
+ toast(msg, title="/name", severity="error", timeout=5)
1033
+
994
1034
  return
995
1035
 
996
1036
  # Best-effort: update local cached run name (used by the quit-time cleanup prompt).
@@ -1006,7 +1046,7 @@ class DashboardScreen(Screen):
1006
1046
  except Exception:
1007
1047
  pass
1008
1048
 
1009
- show(f"Renamed run {run_id} to: {new_name}")
1049
+ toast(f"Renamed run {run_id} to: {new_name}", title="/name", timeout=4)
1010
1050
  return
1011
1051
 
1012
1052
  if cmd == "/status":
@@ -1056,7 +1096,11 @@ class DashboardScreen(Screen):
1056
1096
 
1057
1097
  from kiwi_cli.commands import dispatch
1058
1098
  try:
1059
- lines = await asyncio.to_thread(lambda: dispatch(command, api_client))
1099
+ lines = await self._run_lines_with_refresh_retry(
1100
+ command, lambda c: dispatch(command, c)
1101
+ )
1102
+ if lines is None:
1103
+ return
1060
1104
  show("\n".join(lines))
1061
1105
  except Exception as e:
1062
1106
  show(f"Command error: {e}", is_error=True)
@@ -1070,9 +1114,26 @@ class DashboardScreen(Screen):
1070
1114
  """Fetch and render conversation history without blocking the UI."""
1071
1115
  if not hasattr(self.app, "autobots_client"):
1072
1116
  return
1073
- success, result, _msg = await asyncio.to_thread(
1117
+ success, result, msg = await asyncio.to_thread(
1074
1118
  lambda: self.app.autobots_client.get_action_result(run_id)
1075
1119
  )
1120
+ if (not success) and msg:
1121
+ code = None
1122
+ try:
1123
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
1124
+ if m:
1125
+ code = int(m.group(1))
1126
+ except Exception:
1127
+ code = None
1128
+ if code in (401, 403):
1129
+ try:
1130
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1131
+ except Exception:
1132
+ refreshed = False
1133
+ if refreshed:
1134
+ success, result, msg = await asyncio.to_thread(
1135
+ lambda: self.app.autobots_client.get_action_result(run_id)
1136
+ )
1076
1137
  if not success or not result:
1077
1138
  return
1078
1139
  self._render_conversation_history_from_action_result(result)
@@ -1093,6 +1154,22 @@ class DashboardScreen(Screen):
1093
1154
  offset=offset,
1094
1155
  )
1095
1156
  )
1157
+ if resp.status_code in (401, 403):
1158
+ # Reactive auth guard: force refresh and retry once.
1159
+ try:
1160
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1161
+ except Exception:
1162
+ refreshed = False
1163
+ if refreshed:
1164
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1165
+ resp = await asyncio.to_thread(
1166
+ lambda: list_actions_v1_actions_get.sync_detailed(
1167
+ client=api_client,
1168
+ name=name if name else UNSET,
1169
+ limit=limit,
1170
+ offset=offset,
1171
+ )
1172
+ )
1096
1173
  if resp.status_code != 200 or not resp.parsed:
1097
1174
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1098
1175
  return
@@ -1147,6 +1224,23 @@ class DashboardScreen(Screen):
1147
1224
  offset=int(opts.get("offset", "0")),
1148
1225
  )
1149
1226
  )
1227
+ if resp.status_code in (401, 403):
1228
+ try:
1229
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1230
+ except Exception:
1231
+ refreshed = False
1232
+ if refreshed:
1233
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1234
+ resp = await asyncio.to_thread(
1235
+ lambda: list_action_result_v1_action_results_get.sync_detailed(
1236
+ client=api_client,
1237
+ action_id=opts.get("action_id") or UNSET,
1238
+ action_name=opts.get("action_name") or UNSET,
1239
+ status=status_enum,
1240
+ limit=int(opts.get("limit", "50")),
1241
+ offset=int(opts.get("offset", "0")),
1242
+ )
1243
+ )
1150
1244
  if resp.status_code != 200 or not resp.parsed:
1151
1245
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1152
1246
  return
@@ -1188,6 +1282,21 @@ class DashboardScreen(Screen):
1188
1282
  offset=offset,
1189
1283
  )
1190
1284
  )
1285
+ if resp.status_code in (401, 403):
1286
+ try:
1287
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1288
+ except Exception:
1289
+ refreshed = False
1290
+ if refreshed:
1291
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1292
+ resp = await asyncio.to_thread(
1293
+ lambda: list_action_graphs_v1_action_graphs_get.sync_detailed(
1294
+ client=api_client,
1295
+ name=name if name else UNSET,
1296
+ limit=limit,
1297
+ offset=offset,
1298
+ )
1299
+ )
1191
1300
  if resp.status_code != 200 or not resp.parsed:
1192
1301
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1193
1302
  return
@@ -1242,6 +1351,23 @@ class DashboardScreen(Screen):
1242
1351
  offset=int(opts.get("offset", "0")),
1243
1352
  )
1244
1353
  )
1354
+ if resp.status_code in (401, 403):
1355
+ try:
1356
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1357
+ except Exception:
1358
+ refreshed = False
1359
+ if refreshed:
1360
+ api_client = getattr(getattr(self.app, "autobots_client", None), "client", api_client)
1361
+ resp = await asyncio.to_thread(
1362
+ lambda: list_action_graph_result_v1_action_graphs_results_get.sync_detailed(
1363
+ client=api_client,
1364
+ action_graph_id=opts.get("graph_id") or UNSET,
1365
+ action_graph_name=opts.get("graph_name") or UNSET,
1366
+ status=status_enum,
1367
+ limit=int(opts.get("limit", "50")),
1368
+ offset=int(opts.get("offset", "0")),
1369
+ )
1370
+ )
1245
1371
  if resp.status_code != 200 or not resp.parsed:
1246
1372
  self._show_command_result(command, f"Error: HTTP {resp.status_code}", is_error=True)
1247
1373
  return
@@ -1302,6 +1428,47 @@ class DashboardScreen(Screen):
1302
1428
  return api_client
1303
1429
 
1304
1430
 
1431
+
1432
+ def _http_status_from_lines(self, lines: list[str]) -> int | None:
1433
+ """Extract status code from a commands.py-style error list."""
1434
+ if not lines:
1435
+ return None
1436
+ if len(lines) != 1:
1437
+ return None
1438
+ s = str(lines[0]).strip()
1439
+ m = re.match(r"^Error:\s*HTTP\s*(\d{3})\s*$", s)
1440
+ if not m:
1441
+ return None
1442
+ try:
1443
+ return int(m.group(1))
1444
+ except Exception:
1445
+ return None
1446
+
1447
+
1448
+ async def _run_lines_with_refresh_retry(
1449
+ self,
1450
+ title: str,
1451
+ fn: Callable[[object], list[str]],
1452
+ ) -> list[str] | None:
1453
+ """Run a slash-command lines function with one refresh+retry on 401/403."""
1454
+ api_client = self._get_api_client_for_command(title)
1455
+ if not api_client:
1456
+ return None
1457
+ lines = await asyncio.to_thread(lambda: fn(api_client))
1458
+ code = self._http_status_from_lines(lines)
1459
+ if code in (401, 403):
1460
+ refreshed = False
1461
+ try:
1462
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
1463
+ except Exception:
1464
+ refreshed = False
1465
+ if refreshed:
1466
+ api_client = self._get_api_client_for_command(title)
1467
+ if not api_client:
1468
+ return lines
1469
+ lines = await asyncio.to_thread(lambda: fn(api_client))
1470
+ return lines
1471
+
1305
1472
  def _parse_command_opts(self, command: str, skip: int = 2) -> dict[str, str]:
1306
1473
  """Parse --key value pairs from a slash command string."""
1307
1474
  parts = command.strip().split()
@@ -1543,11 +1710,12 @@ class DashboardScreen(Screen):
1543
1710
 
1544
1711
  async def _on_action_picked_async(self, action_id: str) -> None:
1545
1712
  try:
1546
- api_client = self._get_api_client_for_command("/actions list")
1547
- if not api_client:
1548
- return
1549
1713
  from kiwi_cli.commands import actions_get
1550
- lines = await asyncio.to_thread(lambda: actions_get(api_client, id=action_id))
1714
+ lines = await self._run_lines_with_refresh_retry(
1715
+ "/actions list", lambda c: actions_get(c, id=action_id)
1716
+ )
1717
+ if lines is None:
1718
+ return
1551
1719
  if lines and lines[0].startswith("Error"):
1552
1720
  self._show_command_result("Action selection", f"Action not found: {action_id}", is_error=True)
1553
1721
  return
@@ -1578,11 +1746,12 @@ class DashboardScreen(Screen):
1578
1746
 
1579
1747
  async def _on_run_picked_async(self, run_id: str) -> None:
1580
1748
  try:
1581
- api_client = self._get_api_client_for_command("/runs list")
1582
- if not api_client:
1583
- return
1584
1749
  from kiwi_cli.commands import runs_get
1585
- lines = await asyncio.to_thread(lambda: runs_get(api_client, id=run_id))
1750
+ lines = await self._run_lines_with_refresh_retry(
1751
+ "/runs list", lambda c: runs_get(c, id=run_id)
1752
+ )
1753
+ if lines is None:
1754
+ return
1586
1755
  if lines and lines[0].startswith("Error"):
1587
1756
  self._show_command_result("Run selection", f"Run not found: {run_id}", is_error=True)
1588
1757
  return
@@ -1612,11 +1781,12 @@ class DashboardScreen(Screen):
1612
1781
 
1613
1782
  async def _on_graph_picked_async(self, graph_id: str) -> None:
1614
1783
  try:
1615
- api_client = self._get_api_client_for_command("/graphs list")
1616
- if not api_client:
1617
- return
1618
1784
  from kiwi_cli.commands import graphs_get
1619
- lines = await asyncio.to_thread(lambda: graphs_get(api_client, id=graph_id))
1785
+ lines = await self._run_lines_with_refresh_retry(
1786
+ "/graphs list", lambda c: graphs_get(c, id=graph_id)
1787
+ )
1788
+ if lines is None:
1789
+ return
1620
1790
  if lines and lines[0].startswith("Error"):
1621
1791
  self._show_command_result("Graph selection", f"Graph not found: {graph_id}", is_error=True)
1622
1792
  return
@@ -1642,11 +1812,12 @@ class DashboardScreen(Screen):
1642
1812
 
1643
1813
  async def _on_graph_run_picked_async(self, graph_run_id: str) -> None:
1644
1814
  try:
1645
- api_client = self._get_api_client_for_command("/graph-runs list")
1646
- if not api_client:
1647
- return
1648
1815
  from kiwi_cli.commands import graph_runs_get
1649
- lines = await asyncio.to_thread(lambda: graph_runs_get(api_client, id=graph_run_id))
1816
+ lines = await self._run_lines_with_refresh_retry(
1817
+ "/graph-runs list", lambda c: graph_runs_get(c, id=graph_run_id)
1818
+ )
1819
+ if lines is None:
1820
+ return
1650
1821
  if lines and lines[0].startswith("Error"):
1651
1822
  self._show_command_result("Graph run selection", f"Graph run not found: {graph_run_id}", is_error=True)
1652
1823
  return
@@ -2188,17 +2359,48 @@ class DashboardScreen(Screen):
2188
2359
  return
2189
2360
 
2190
2361
  if not success:
2191
- self.add_message(f"Error starting action: {message}", "error")
2192
- # Remove the placeholder row (no run was started).
2362
+ # Reactive auth guard: if the request failed with 401/403, force a token
2363
+ # refresh and retry once before surfacing the error.
2364
+ code = None
2193
2365
  try:
2194
- w = getattr(self, "_streaming_widget_ref", None)
2195
- if w:
2196
- w.remove()
2366
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
2367
+ if m:
2368
+ code = int(m.group(1))
2197
2369
  except Exception:
2198
- pass
2199
- self._streaming_widget_ref = None
2200
- self._set_streaming(False)
2201
- return
2370
+ code = None
2371
+
2372
+ if code in (401, 403):
2373
+ try:
2374
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
2375
+ except Exception:
2376
+ refreshed = False
2377
+ if refreshed:
2378
+ try:
2379
+ success, run_id, message = await loop.run_in_executor(
2380
+ None,
2381
+ lambda: client.run_action_async(
2382
+ self.current_action_id,
2383
+ user_input,
2384
+ action_result_id=self.current_run_id,
2385
+ urls=urls if urls else None,
2386
+ metadata=self._metadata if self._metadata else None,
2387
+ ),
2388
+ )
2389
+ except Exception:
2390
+ pass
2391
+
2392
+ if not success:
2393
+ self.add_message(f"Error starting action: {message}", "error")
2394
+ # Remove the placeholder row (no run was started).
2395
+ try:
2396
+ w = getattr(self, "_streaming_widget_ref", None)
2397
+ if w:
2398
+ w.remove()
2399
+ except Exception:
2400
+ pass
2401
+ self._streaming_widget_ref = None
2402
+ self._set_streaming(False)
2403
+ return
2202
2404
 
2203
2405
  # Check if this is continuing an existing conversation
2204
2406
  if self.current_run_id and run_id == self.current_run_id:
@@ -2312,17 +2514,51 @@ class DashboardScreen(Screen):
2312
2514
  if got_final_result:
2313
2515
  return True
2314
2516
 
2517
+ auth_failed_code: int | None = None
2518
+
2315
2519
  try:
2316
2520
  async with fetch_lock:
2317
2521
  if got_final_result:
2318
2522
  return True
2319
- success, final_result, _message = await asyncio.to_thread(
2523
+ success, final_result, message = await asyncio.to_thread(
2320
2524
  client.get_action_result, run_id
2321
2525
  )
2526
+
2527
+ if not success:
2528
+ code = None
2529
+ try:
2530
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(message or ""))
2531
+ if m:
2532
+ code = int(m.group(1))
2533
+ except Exception:
2534
+ code = None
2535
+
2536
+ if code in (401, 403):
2537
+ try:
2538
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
2539
+ except Exception:
2540
+ refreshed = False
2541
+ if refreshed:
2542
+ success, final_result, message = await asyncio.to_thread(
2543
+ client.get_action_result, run_id
2544
+ )
2545
+ if (not success) and (code in (401, 403)):
2546
+ auth_failed_code = code
2322
2547
  except Exception as e:
2323
2548
  logger.warning(f"Poll error: {e}")
2324
2549
  return False
2325
2550
 
2551
+
2552
+ if auth_failed_code in (401, 403):
2553
+ self.add_message(
2554
+ f"Authentication error while fetching result (HTTP {auth_failed_code}). Please /login again.",
2555
+ "error",
2556
+ )
2557
+ got_final_result = True
2558
+ # Stop spinner/blink immediately when we have a terminal state.
2559
+ self._set_streaming(False)
2560
+ return True
2561
+
2326
2562
  if not success or not final_result:
2327
2563
  return False
2328
2564
 
@@ -2613,7 +2849,22 @@ class DashboardScreen(Screen):
2613
2849
  """
2614
2850
  if not hasattr(self.app, "autobots_client"):
2615
2851
  return
2616
- success, result, _msg = self.app.autobots_client.get_action_result(run_id)
2852
+ success, result, msg = self.app.autobots_client.get_action_result(run_id)
2853
+ if (not success) and msg:
2854
+ code = None
2855
+ try:
2856
+ m = re.search(r"(?:HTTP|status)\s*(\d{3})", str(msg))
2857
+ if m:
2858
+ code = int(m.group(1))
2859
+ except Exception:
2860
+ code = None
2861
+ if code in (401, 403):
2862
+ try:
2863
+ refreshed = bool(self.app._refresh_token_if_needed(force=True))
2864
+ except Exception:
2865
+ refreshed = False
2866
+ if refreshed:
2867
+ success, result, msg = self.app.autobots_client.get_action_result(run_id)
2617
2868
  if not success or not result:
2618
2869
  return
2619
2870
  self._render_conversation_history_from_action_result(result)
@@ -397,7 +397,7 @@ wheels = [
397
397
 
398
398
  [[package]]
399
399
  name = "kiwi-code"
400
- version = "0.0.30"
400
+ version = "0.0.32"
401
401
  source = { editable = "." }
402
402
  dependencies = [
403
403
  { name = "autobots-client" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes