Skip to content

Commit 2f3058e

Browse files
committed
feat(serve,ui): optional initial prompt on session create
POST /api/sessions now accepts { initial_prompt: string }. When non-empty, the createSpawner sleeps 3s (so claude has time to boot and show its prompt) and then sends the text + Enter into the pane via the existing tmux send-keys seam. Fire-and-forget: errors are logged, never fail the 201 response. CreateSpawner interface gains SendInitialPrompt(name, text); tests update the fake with an initialCalls/initialArgs capture. NewSessionModal grows a 3-row textarea below the workdir + recents block, 16px font to avoid iOS zoom, trimmed + omitted when blank.
1 parent 4e27198 commit 2f3058e

5 files changed

Lines changed: 57 additions & 6 deletions

File tree

internal/serve/api/create.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import (
1919
// without spawning real tmux + claude.
2020
type CreateSpawner interface {
2121
Spawn(name, workdir string) (session.Session, error)
22+
// SendInitialPrompt fires a one-shot prompt into the new session
23+
// after claude has had time to boot. Fire-and-forget; errors are
24+
// logged but don't fail the create response.
25+
SendInitialPrompt(name, text string)
2226
}
2327

2428
// CreateLookPath is the seam used for the "claude on PATH" check.
@@ -28,8 +32,9 @@ type CreateLookPath interface {
2832
}
2933

3034
type createReqBody struct {
31-
Workdir string `json:"workdir"`
32-
Name string `json:"name,omitempty"`
35+
Workdir string `json:"workdir"`
36+
Name string `json:"name,omitempty"`
37+
InitialPrompt string `json:"initial_prompt,omitempty"`
3338
}
3439

3540
var createNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
@@ -105,6 +110,10 @@ func CreateSession(src InputSessionSource, sp CreateSpawner, lp CreateLookPath)
105110
return
106111
}
107112

113+
if prompt := strings.TrimRight(body.InitialPrompt, " \t\n\r"); prompt != "" {
114+
sp.SendInitialPrompt(sess.Name, prompt)
115+
}
116+
108117
w.Header().Set("Content-Type", "application/json")
109118
w.WriteHeader(http.StatusCreated)
110119
_ = json.NewEncoder(w).Encode(sess)

internal/serve/api/create_test.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ func (f *fakeCreateProj) Get(name string) (session.Session, bool) {
3030
func (f *fakeCreateProj) TmuxAlive(name string) bool { return true }
3131

3232
type fakeCreateSpawner struct {
33-
returnSess session.Session
34-
err error
35-
calledWith struct{ name, workdir string }
36-
called int
33+
returnSess session.Session
34+
err error
35+
calledWith struct{ name, workdir string }
36+
called int
37+
initialCalls int
38+
initialArgs struct{ name, text string }
3739
}
3840

3941
func (f *fakeCreateSpawner) Spawn(name, workdir string) (session.Session, error) {
@@ -49,6 +51,12 @@ func (f *fakeCreateSpawner) Spawn(name, workdir string) (session.Session, error)
4951
return s, nil
5052
}
5153

54+
func (f *fakeCreateSpawner) SendInitialPrompt(name, text string) {
55+
f.initialCalls++
56+
f.initialArgs.name = name
57+
f.initialArgs.text = text
58+
}
59+
5260
type fakeLookPath struct{ ok bool }
5361

5462
func (f fakeLookPath) LookPath(file string) (string, error) {

internal/serve/server.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,23 @@ func (c createSpawner) Spawn(name, workdir string) (session.Session, error) {
865865
})
866866
}
867867

868+
// SendInitialPrompt fires `text` into the new session's pane after a
869+
// short delay so claude has time to boot and show its prompt. Runs
870+
// in a goroutine — fire-and-forget; errors are logged, not returned.
871+
func (c createSpawner) SendInitialPrompt(name, text string) {
872+
go func() {
873+
time.Sleep(3 * time.Second)
874+
target := name + ":0.0"
875+
if err := c.tmux.SendKeys(target, text); err != nil {
876+
slog.Warn("initial prompt send failed", "session", name, "err", err.Error())
877+
return
878+
}
879+
if err := c.tmux.SendEnter(target); err != nil {
880+
slog.Warn("initial prompt enter failed", "session", name, "err", err.Error())
881+
}
882+
}()
883+
}
884+
868885
// execLookPath is a tiny adapter so api.CreateLookPath can be
869886
// satisfied by the free function os/exec.LookPath.
870887
type execLookPath struct{}

ui/src/components/NewSessionModal.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function NewSessionModal({ open, onClose, recents }: NewSessionModalProps
2929
const create = useCreateSession();
3030
const [workdir, setWorkdir] = useState(recents[0] ?? "");
3131
const [name, setName] = useState<string>("");
32+
const [initialPrompt, setInitialPrompt] = useState<string>("");
3233
const [collision, setCollision] = useState<CreateConflict | null>(null);
3334
const [renaming, setRenaming] = useState(false);
3435
const [errMsg, setErrMsg] = useState<string | null>(null);
@@ -37,6 +38,7 @@ export function NewSessionModal({ open, onClose, recents }: NewSessionModalProps
3738
if (!open) return;
3839
setWorkdir(recents[0] ?? "");
3940
setName("");
41+
setInitialPrompt("");
4042
setCollision(null);
4143
setRenaming(false);
4244
setErrMsg(null);
@@ -46,9 +48,11 @@ export function NewSessionModal({ open, onClose, recents }: NewSessionModalProps
4648
async function handleSubmit() {
4749
setErrMsg(null);
4850
try {
51+
const trimmedPrompt = initialPrompt.trim();
4952
const sess = await create.mutateAsync({
5053
workdir,
5154
...(renaming && name ? { name } : {}),
55+
...(trimmedPrompt ? { initial_prompt: trimmedPrompt } : {}),
5256
});
5357
navigate(`/s/${encodeURIComponent(sess.name)}`);
5458
onClose();
@@ -150,6 +154,18 @@ export function NewSessionModal({ open, onClose, recents }: NewSessionModalProps
150154
</div>
151155
)}
152156

157+
<label className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
158+
Initial prompt <span className="font-normal normal-case tracking-normal text-fg-dim">(optional — sent after boot)</span>
159+
</label>
160+
<textarea
161+
aria-label="Initial prompt"
162+
value={initialPrompt}
163+
onChange={(e) => setInitialPrompt(e.target.value)}
164+
placeholder="e.g. review the diff on main and suggest follow-ups"
165+
rows={3}
166+
className="mb-3 block w-full resize-y rounded border border-border bg-bg px-2 py-1.5 font-mono text-[16px] text-fg placeholder:text-fg-dim focus:outline-none focus:ring-1 focus:ring-accent-gold sm:text-xs"
167+
/>
168+
153169
{errMsg && (
154170
<div
155171
role="alert"

ui/src/hooks/useCreateSession.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Session } from "@/hooks/useSessions";
55
export interface CreateSessionBody {
66
workdir: string;
77
name?: string;
8+
initial_prompt?: string;
89
}
910

1011
export interface CreateConflict {

0 commit comments

Comments
 (0)