Skip to content

Commit 6546576

Browse files
authored
Merge pull request #2 from Cicolas/worktree-fix-timeout-reading
Replace fixed timeout with output-stability detection; add ctrl+c to cancel tool calls
2 parents 4f2ed6c + 35061f8 commit 6546576

File tree

9 files changed

+205
-36
lines changed

9 files changed

+205
-36
lines changed

.claude/skills/finish-worktree/SKILL.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
name: finish-worktree
3-
description: Commit all changes in the current worktree and open a PR targeting the shared release branch. Usage: /finish-worktree [release-branch] — defaults to "release/next".
3+
description: Commit all changes in the current worktree and open a PR targeting the release branch for the current Cargo.toml version. Usage: /finish-worktree [release-branch] — defaults to release/v<CARGO_VERSION>.
44
argument-hint: "[release-branch]"
55
---
66

@@ -20,8 +20,13 @@ Run these in parallel:
2020

2121
### 2. Determine the release branch
2222

23-
If the user provided an argument (e.g. `/finish-worktree release/v2`), use that as the release branch name.
24-
Otherwise use `release/next` as the default.
23+
If the user provided an argument (e.g. `/finish-worktree release/v1.2.0`), use that as the release branch name.
24+
25+
Otherwise read the version from `Cargo.toml`:
26+
```
27+
grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/'
28+
```
29+
Use `release/v<version>` (e.g. `release/v0.1.0`) as the release branch name.
2530

2631
### 3. Ensure the release branch exists on the remote
2732

@@ -63,7 +68,7 @@ EOF
6368
)"
6469
```
6570

66-
If there is nothing to commit (working tree clean), skip to step 5.
71+
If there is nothing to commit (working tree clean), skip to step 6.
6772

6873
### 6. Push the current branch to remote
6974

@@ -100,4 +105,4 @@ If a PR already exists for this branch (`gh pr list --head <branch>`), skip crea
100105
Print a short summary:
101106
- Commit hash (or "nothing to commit")
102107
- PR URL
103-
- Release branch targeted
108+
- Release branch targeted
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
---
2+
name: ship-release
3+
description: Review all open PRs targeting the current release branch, interactively ask whether each should be included, then create a PR from the release branch to main. Usage: /ship-release
4+
---
5+
6+
Review open PRs targeting the current release branch, confirm which to include, and open a release PR to main.
7+
8+
## Steps
9+
10+
Follow these steps in order without skipping any.
11+
12+
### 1. Determine the release branch
13+
14+
Read the version from `Cargo.toml`:
15+
```
16+
grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/'
17+
```
18+
The release branch is `release/v<version>` (e.g. `release/v0.1.0`).
19+
20+
### 2. List open PRs targeting the release branch
21+
22+
```
23+
gh pr list --base <release-branch> --state open --json number,title,headRefName,author,url,body
24+
```
25+
26+
If there are no open PRs, skip to step 4.
27+
28+
### 3. Ask the user about each open PR
29+
30+
For each PR found, display:
31+
- PR number, title, author, branch name, URL
32+
33+
Then check the PR body for a "## Tests" section containing a markdown checklist. If any checklist items are unchecked (lines starting with `- [ ]`), warn the user:
34+
> "⚠️ PR #<N> has unchecked test cases in the Tests section. Please verify them before including."
35+
> List the unchecked items.
36+
37+
Then ask the user:
38+
> "Include PR #<N> — <title> (<branch>)? [y/n]"
39+
40+
Wait for the user's answer before moving to the next PR. Collect the list of PRs the user said **yes** to.
41+
42+
For PRs the user said **yes** to, ensure they are merged into the release branch:
43+
- If the PR is already merged, skip.
44+
- If not yet merged, merge it:
45+
```
46+
gh pr merge <number> --merge --auto
47+
```
48+
Wait and confirm merge completed before continuing.
49+
50+
### 4. Check whether a release PR already exists
51+
52+
```
53+
gh pr list --head <release-branch> --base main --state open --json number,url
54+
```
55+
56+
If one already exists, print its URL and skip to step 6.
57+
58+
### 5. Create the release PR
59+
60+
```
61+
gh pr create \
62+
--base main \
63+
--head <release-branch> \
64+
--title "Release v<version>" \
65+
--body "$(cat <<'EOF'
66+
## Release v<version>
67+
68+
### Included changes
69+
<bullet list of merged PR titles and numbers that were included>
70+
71+
🤖 Generated with [Claude Code](https://claude.com/claude-code)
72+
EOF
73+
)"
74+
```
75+
76+
### 6. Report
77+
78+
Print a short summary:
79+
- Release branch
80+
- PRs included (title + number)
81+
- PRs skipped (title + number)
82+
- Release PR URL

.github/workflows/release.yml

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
name: Release AppImage
22

33
on:
4+
pull_request:
5+
types: [closed]
6+
branches:
7+
- main
48
push:
59
tags:
610
- "v*"
@@ -10,11 +14,32 @@ permissions:
1014

1115
jobs:
1216
build-appimage:
17+
if: >
18+
(github.event_name == 'push') ||
19+
(github.event_name == 'pull_request' &&
20+
github.event.pull_request.merged == true &&
21+
startsWith(github.event.pull_request.head.ref, 'release/'))
1322
runs-on: ubuntu-22.04
1423

1524
steps:
1625
- uses: actions/checkout@v4
1726

27+
- name: Resolve version tag
28+
id: version
29+
run: |
30+
if [[ "${{ github.event_name }}" == "push" ]]; then
31+
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
32+
else
33+
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/')
34+
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
35+
fi
36+
37+
- name: Create and push tag
38+
if: github.event_name == 'pull_request'
39+
run: |
40+
git tag "${{ steps.version.outputs.tag }}"
41+
git push origin "${{ steps.version.outputs.tag }}"
42+
1843
- name: Install system dependencies
1944
run: |
2045
sudo apt-get update
@@ -79,11 +104,11 @@ jobs:
79104
env:
80105
ARCH: x86_64
81106
run: |
82-
# appimagetool needs FUSE; use extract-and-run workaround in CI
83-
appimagetool --appimage-extract-and-run AppDir sheesh-rs-${{ github.ref_name }}-x86_64.AppImage
107+
appimagetool --appimage-extract-and-run AppDir sheesh-rs-${{ steps.version.outputs.tag }}-x86_64.AppImage
84108
85109
- name: Create GitHub Release
86110
uses: softprops/action-gh-release@v2
87111
with:
88-
files: sheesh-rs-${{ github.ref_name }}-x86_64.AppImage
112+
tag_name: ${{ steps.version.outputs.tag }}
113+
files: sheesh-rs-${{ steps.version.outputs.tag }}-x86_64.AppImage
89114
generate_release_notes: true

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sheesh-rs"
3-
version = "0.1.0"
3+
version = "1.0.5"
44
edition = "2024"
55

66
[dependencies]

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ A terminal UI for managing SSH connections with an embedded LLM assistant.
1010

1111
- **Connection manager** — CRUD SSH connections backed by `~/.ssh/config`; comments above a `Host` block become its description
1212
- **Embedded terminal** — connects over a PTY so the full SSH session runs inside the TUI; resizes with the window
13-
- **LLM sidebar** — chat with an AI assistant while connected; press `F3` to send the last 50 terminal lines as context
13+
- **LLM sidebar** — chat with an AI assistant while connected; Claude automatically reads terminal output via the `read_terminal` tool when you ask about what's on screen
1414
- **Tool use** — Claude can propose shell commands to run on your remote session; you approve each one before it executes
1515
- **Multi-provider LLM** — Anthropic (default), OpenAI, or a local Ollama instance
1616
- **System prompt** — a built-in prompt configures Claude as an SSH/Linux assistant; override it in config
@@ -37,7 +37,9 @@ cargo build --release
3737

3838
### Release a new version (maintainers)
3939

40-
Tag a commit — the CI workflow builds and publishes the AppImage automatically:
40+
Merge a `release/v<version>` branch into `main` — the CI workflow tags the commit and publishes the AppImage automatically.
41+
42+
Alternatively, push a tag directly:
4143

4244
```bash
4345
git tag v1.0.0
@@ -76,7 +78,6 @@ ollama_model = "llama3"
7678
| `a / e / d` | Listing | Add / Edit / Delete |
7779
| `/` | Listing | Filter |
7880
| `F2` | Connected | Switch panel (terminal ↔ LLM) |
79-
| `F3` | Connected | Send last 50 terminal lines to LLM |
8081
| `ctrl+d` | Terminal | Disconnect |
8182
| `ctrl+up / down` | Terminal or LLM | Scroll history |
8283
| `enter` | LLM | Send message |

src/event.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ pub enum Action {
99
Disconnect,
1010
/// Send a command string to the terminal PTY (no trailing newline).
1111
SendToTerminal(String),
12+
/// Cancel an in-progress tool call and return to the user prompt.
13+
CancelToolCall,
1214
/// No-op
1315
None,
1416
}

src/main.rs

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ use tabs::{Tab, listing::ListingTab, llm::LLMTab, terminal::TerminalTab};
3030
use ui::{keybindings::render_keybindings, theme::Theme};
3131

3232
/// Captures terminal output produced by a tool-call command and forwards it
33-
/// to the LLM once the deadline elapses.
33+
/// to the LLM once the output has been stable (no new lines) for a short period.
3434
struct PendingCapture {
3535
/// Number of terminal lines present *before* the command was sent.
3636
snapshot: usize,
37-
/// When to stop waiting and send whatever has been captured so far.
38-
deadline: std::time::Instant,
37+
/// Line count at the last tick where output was still growing.
38+
last_line_count: usize,
39+
/// When the line count last changed (used to detect output stability).
40+
last_change: std::time::Instant,
3941
}
4042

4143
struct Sheesh {
@@ -92,7 +94,11 @@ impl Sheesh {
9294
let provider = build_provider(&self.llm_config);
9395
let output_log = terminal.output_log_arc();
9496
self.terminal = Some(terminal);
95-
let mut llm = LLMTab::new(provider, self.llm_config.system_prompt.clone(), conn.clone());
97+
let mut llm = LLMTab::new(
98+
provider,
99+
self.llm_config.system_prompt.clone(),
100+
conn.clone(),
101+
);
96102
llm.set_terminal_output(output_log);
97103
self.llm = Some(llm);
98104
self.state = AppState::Connected {
@@ -193,17 +199,28 @@ impl Sheesh {
193199
match action {
194200
Action::Quit => return false,
195201
Action::Disconnect => self.disconnect(),
202+
Action::CancelToolCall => {
203+
self.pending_capture = None;
204+
if let Some(llm) = &mut self.llm {
205+
llm.cancel_tool_call();
206+
}
207+
if let Some(terminal) = &mut self.terminal {
208+
terminal.set_tool_locked(false);
209+
}
210+
}
196211
Action::SendToTerminal(cmd) => {
197212
if let Some(t) = &mut self.terminal {
198213
let snapshot = t.line_count();
199214
t.send_string(&cmd);
200215
t.send_string("\r");
201216
t.set_tool_locked(true);
202-
// Capture output for 1.5 s then forward it to Claude.
217+
// Wait for output to stabilise (300 ms of silence) then
218+
// forward it to Claude. The user can press ctrl+c to cancel.
219+
let now = std::time::Instant::now();
203220
self.pending_capture = Some(PendingCapture {
204221
snapshot,
205-
deadline: std::time::Instant::now()
206-
+ std::time::Duration::from_millis(1500),
222+
last_line_count: snapshot,
223+
last_change: now,
207224
});
208225
}
209226
if let AppState::Connected { ref mut focus, .. } = self.state {
@@ -289,11 +306,9 @@ impl Sheesh {
289306
.as_ref()
290307
.map(|t| t.key_hints())
291308
.unwrap_or_default(),
292-
ConnectedFocus::LLM => self
293-
.llm
294-
.as_ref()
295-
.map(|l| l.key_hints())
296-
.unwrap_or_default(),
309+
ConnectedFocus::LLM => {
310+
self.llm.as_ref().map(|l| l.key_hints()).unwrap_or_default()
311+
}
297312
};
298313
hints.extend(panel_hints);
299314
hints.push(("ctrl+q", "quit"));
@@ -366,12 +381,26 @@ fn main() -> anyhow::Result<()> {
366381
loop {
367382
terminal.draw(|f| app.draw(f))?;
368383

369-
// Forward captured terminal output to Claude once the deadline elapses.
370-
if let Some(ref cap) = app.pending_capture
371-
&& std::time::Instant::now() >= cap.deadline
372-
{
373-
let snapshot = cap.snapshot;
374-
app.pending_capture = None;
384+
// Forward captured terminal output to Claude once output has been
385+
// stable (no new PTY lines) for 300 ms.
386+
let should_fire = if let Some(ref mut cap) = app.pending_capture {
387+
let now = std::time::Instant::now();
388+
let current = app.terminal.as_ref().map_or(0, |t| t.line_count());
389+
if current > cap.last_line_count {
390+
cap.last_line_count = current;
391+
cap.last_change = now;
392+
}
393+
let silence = now.duration_since(cap.last_change);
394+
let has_output = cap.last_line_count > cap.snapshot;
395+
// Wait for output to appear, then stabilise for 300 ms.
396+
// If the command produces no output at all, fire after 5 s.
397+
(has_output && silence >= Duration::from_millis(300))
398+
|| (!has_output && silence >= Duration::from_secs(5))
399+
} else {
400+
false
401+
};
402+
if should_fire {
403+
let snapshot = app.pending_capture.take().unwrap().snapshot;
375404
if let (Some(terminal), Some(llm)) = (&app.terminal, &mut app.llm)
376405
&& llm.awaiting_output_id.is_some()
377406
{
@@ -382,7 +411,9 @@ fn main() -> anyhow::Result<()> {
382411

383412
// Release the tool lock once the LLM finishes the tool-execution cycle.
384413
if let (Some(terminal), Some(llm)) = (&mut app.terminal, &app.llm)
385-
&& terminal.tool_locked && !llm.is_executing_tool() && !llm.waiting
414+
&& terminal.tool_locked
415+
&& !llm.is_executing_tool()
416+
&& !llm.waiting
386417
{
387418
terminal.set_tool_locked(false);
388419
}
@@ -413,7 +444,10 @@ fn load_llm_config() -> LLMConfig {
413444

414445
match std::fs::read_to_string(&path) {
415446
Err(e) => {
416-
log::warn!("[config] could not read config file: {} — using defaults", e);
447+
log::warn!(
448+
"[config] could not read config file: {} — using defaults",
449+
e
450+
);
417451
}
418452
Ok(content) => {
419453
#[derive(serde::Deserialize, Default)]
@@ -423,10 +457,17 @@ fn load_llm_config() -> LLMConfig {
423457
}
424458
match toml::from_str::<ConfigFile>(&content) {
425459
Err(e) => {
426-
log::error!("[config] failed to parse config.toml: {} — using defaults", e);
460+
log::error!(
461+
"[config] failed to parse config.toml: {} — using defaults",
462+
e
463+
);
427464
}
428465
Ok(cfg) => {
429-
log::info!("[config] loaded: provider={} model={}", cfg.llm.provider, cfg.llm.model);
466+
log::info!(
467+
"[config] loaded: provider={} model={}",
468+
cfg.llm.provider,
469+
cfg.llm.model
470+
);
430471
return cfg.llm;
431472
}
432473
}

0 commit comments

Comments
 (0)