Skip to content

Commit de34f90

Browse files
committed
feat(tui): 增加 wake 启动一次性自动提交链路
1 parent 4e168c0 commit de34f90

7 files changed

Lines changed: 274 additions & 14 deletions

File tree

internal/app/bootstrap.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"neo-code/internal/config"
1414
configstate "neo-code/internal/config/state"
1515
agentcontext "neo-code/internal/context"
16+
"neo-code/internal/gateway/protocol"
1617
"neo-code/internal/memo"
1718
"neo-code/internal/provider"
1819
"neo-code/internal/provider/builtin"
@@ -55,8 +56,9 @@ var (
5556

5657
// BootstrapOptions 描述应用启动时可注入的运行时选项。
5758
type BootstrapOptions struct {
58-
Workdir string
59-
SessionID string
59+
Workdir string
60+
SessionID string
61+
WakeInputB64 string
6062
}
6163

6264
type memoExtractorScheduler interface {
@@ -271,6 +273,21 @@ func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, func(
271273
return nil, nil, err
272274
}
273275
}
276+
if encodedWakeInput := strings.TrimSpace(opts.WakeInputB64); encodedWakeInput != "" {
277+
wakeInput, decodeErr := protocol.DecodeWakeStartupInput(encodedWakeInput)
278+
if decodeErr != nil {
279+
if cleanup != nil {
280+
_ = cleanup()
281+
}
282+
return nil, nil, decodeErr
283+
}
284+
if configureErr := tuiApp.ConfigureStartupWakeInput(wakeInput.Text, wakeInput.Workdir); configureErr != nil {
285+
if cleanup != nil {
286+
_ = cleanup()
287+
}
288+
return nil, nil, configureErr
289+
}
290+
}
274291
return tea.NewProgram(
275292
tuiApp,
276293
tea.WithAltScreen(),

internal/app/bootstrap_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919

2020
"neo-code/internal/config"
2121
configstate "neo-code/internal/config/state"
22+
"neo-code/internal/gateway/protocol"
2223
"neo-code/internal/memo"
2324
"neo-code/internal/provider"
2425
providertypes "neo-code/internal/provider/types"
@@ -1868,6 +1869,70 @@ func TestNewProgramFailsFastWhenHydrationFails(t *testing.T) {
18681869
}
18691870
}
18701871

1872+
func TestNewProgramAcceptsWakeStartupPayload(t *testing.T) {
1873+
disableBuiltinProviderAPIKeys(t)
1874+
home := t.TempDir()
1875+
t.Setenv("HOME", home)
1876+
t.Setenv("USERPROFILE", home)
1877+
1878+
originalFactory := newRemoteRuntimeAdapter
1879+
t.Cleanup(func() { newRemoteRuntimeAdapter = originalFactory })
1880+
1881+
stubRuntime := &stubRemoteRuntimeForBootstrap{
1882+
events: make(chan services.RuntimeEvent),
1883+
}
1884+
newRemoteRuntimeAdapter = func(_ services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
1885+
return stubRuntime, nil
1886+
}
1887+
1888+
encodedWakeInput, err := protocol.EncodeWakeStartupInput(protocol.WakeStartupInput{
1889+
Text: "hello from wake",
1890+
Workdir: home,
1891+
})
1892+
if err != nil {
1893+
t.Fatalf("EncodeWakeStartupInput() error = %v", err)
1894+
}
1895+
1896+
_, cleanup, err := NewProgram(context.Background(), BootstrapOptions{WakeInputB64: encodedWakeInput})
1897+
if err != nil {
1898+
t.Fatalf("NewProgram() error = %v", err)
1899+
}
1900+
if cleanup == nil {
1901+
t.Fatal("expected non-nil cleanup")
1902+
}
1903+
if err := cleanup(); err != nil {
1904+
t.Fatalf("cleanup() error = %v", err)
1905+
}
1906+
}
1907+
1908+
func TestNewProgramFailsFastWhenWakeStartupPayloadInvalid(t *testing.T) {
1909+
disableBuiltinProviderAPIKeys(t)
1910+
home := t.TempDir()
1911+
t.Setenv("HOME", home)
1912+
t.Setenv("USERPROFILE", home)
1913+
1914+
originalFactory := newRemoteRuntimeAdapter
1915+
t.Cleanup(func() { newRemoteRuntimeAdapter = originalFactory })
1916+
1917+
stubRuntime := &stubRemoteRuntimeForBootstrap{
1918+
events: make(chan services.RuntimeEvent),
1919+
}
1920+
newRemoteRuntimeAdapter = func(_ services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
1921+
return stubRuntime, nil
1922+
}
1923+
1924+
_, cleanup, err := NewProgram(context.Background(), BootstrapOptions{WakeInputB64: "not-base64"})
1925+
if cleanup != nil {
1926+
t.Fatal("expected nil cleanup when wake payload decode failed")
1927+
}
1928+
if err == nil {
1929+
t.Fatal("expected wake payload decode error")
1930+
}
1931+
if !stubRuntime.closed {
1932+
t.Fatal("expected runtime cleanup on wake payload decode failure")
1933+
}
1934+
}
1935+
18711936
func TestNewProgramFailsFastWhenRemoteAdapterInitFails(t *testing.T) {
18721937
disableBuiltinProviderAPIKeys(t)
18731938
home := t.TempDir()

internal/cli/root.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ var (
3939

4040
// GlobalFlags 描述根命令共享的全局启动参数。
4141
type GlobalFlags struct {
42-
Workdir string
43-
Session string
42+
Workdir string
43+
Session string
44+
WakeInputB64 string
4445
}
4546

4647
// Execute 执行 NeoCode 根命令入口,并在退出前等待静默更新检查收尾。
@@ -79,16 +80,21 @@ func NewRootCommand() *cobra.Command {
7980
RunE: func(cmd *cobra.Command, args []string) error {
8081
flags.Workdir = strings.TrimSpace(settings.GetString("workdir"))
8182
flags.Session = strings.TrimSpace(settings.GetString("session"))
83+
flags.WakeInputB64 = strings.TrimSpace(settings.GetString("wake-input-b64"))
8284
return launchRootProgram(cmd.Context(), app.BootstrapOptions{
83-
Workdir: flags.Workdir,
84-
SessionID: flags.Session,
85+
Workdir: flags.Workdir,
86+
SessionID: flags.Session,
87+
WakeInputB64: flags.WakeInputB64,
8588
})
8689
},
8790
}
8891
cmd.PersistentFlags().String("workdir", "", "workdir override for current run")
8992
cmd.PersistentFlags().String("session", "", "session id to hydrate on startup")
93+
cmd.PersistentFlags().String("wake-input-b64", "", "internal wake startup payload")
94+
_ = cmd.PersistentFlags().MarkHidden("wake-input-b64")
9095
_ = settings.BindPFlag("workdir", cmd.PersistentFlags().Lookup("workdir"))
9196
_ = settings.BindPFlag("session", cmd.PersistentFlags().Lookup("session"))
97+
_ = settings.BindPFlag("wake-input-b64", cmd.PersistentFlags().Lookup("wake-input-b64"))
9298
cmd.AddCommand(
9399
newGatewayCommand(),
94100
newDaemonCommand(),

internal/cli/root_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,49 @@ func TestExecuteUsesOSArgs(t *testing.T) {
115115
}
116116
}
117117

118+
func TestNewRootCommandPassesWakeInputFlagToLauncher(t *testing.T) {
119+
originalLauncher := launchRootProgram
120+
t.Cleanup(func() { launchRootProgram = originalLauncher })
121+
122+
var captured app.BootstrapOptions
123+
launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error {
124+
captured = opts
125+
return nil
126+
}
127+
128+
cmd := NewRootCommand()
129+
cmd.SetArgs([]string{"--wake-input-b64", "payload-123"})
130+
if err := cmd.ExecuteContext(context.Background()); err != nil {
131+
t.Fatalf("ExecuteContext() error = %v", err)
132+
}
133+
if captured.WakeInputB64 != "payload-123" {
134+
t.Fatalf("wake input = %q, want %q", captured.WakeInputB64, "payload-123")
135+
}
136+
}
137+
138+
func TestExecuteUsesOSArgsWithWakeInput(t *testing.T) {
139+
originalLauncher := launchRootProgram
140+
originalArgs := os.Args
141+
t.Cleanup(func() {
142+
launchRootProgram = originalLauncher
143+
os.Args = originalArgs
144+
})
145+
146+
var captured app.BootstrapOptions
147+
launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error {
148+
captured = opts
149+
return nil
150+
}
151+
os.Args = []string{"neocode", "--wake-input-b64", "payload-xyz"}
152+
153+
if err := Execute(context.Background()); err != nil {
154+
t.Fatalf("Execute() error = %v", err)
155+
}
156+
if captured.WakeInputB64 != "payload-xyz" {
157+
t.Fatalf("wake input = %q, want %q", captured.WakeInputB64, "payload-xyz")
158+
}
159+
}
160+
118161
func TestExecuteWaitsForSilentUpdateCheckCompletion(t *testing.T) {
119162
originalLauncher := launchRootProgram
120163
originalPreload := runGlobalPreload

internal/tui/core/app/app.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ type appRuntimeState struct {
126126
queuedIntervention *queuedInterventionInput
127127
pendingAutoPermission *autoPermissionApprovalState
128128
pendingFullAccessPrompt *fullAccessPromptState
129+
startupWakeSubmitInput *startupWakeSubmitInput
129130
fullAccessModeEnabled bool
130131
pendingImageAttachments []pendingImageAttachment
131132
pendingPasteBuffers []pendingPasteBuffer
@@ -177,6 +178,11 @@ type queuedInterventionInput struct {
177178
Images []tuiservices.UserImageInput
178179
}
179180

181+
type startupWakeSubmitInput struct {
182+
Text string
183+
Workdir string
184+
}
185+
180186
// providerAddFormState 保存添加新 provider 表单的状态。
181187
type providerAddFormState struct {
182188
Stage providerAddFormStage
@@ -416,6 +422,9 @@ func (a App) Init() tea.Cmd {
416422
a.spinner.Tick,
417423
appTickCmd(),
418424
}
425+
if a.startupWakeSubmitInput != nil {
426+
cmds = append(cmds, emitStartupWakeSubmitCmd(*a.startupWakeSubmitInput))
427+
}
419428
if cmd := runModelCatalogRefresh(a.providerSvc, a.modelRefreshID); cmd != nil {
420429
cmds = append(cmds, cmd)
421430
}

internal/tui/core/app/update.go

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ var deleteProviderUserEnvVar = config.DeleteUserEnvVar
7373
var lookupProviderUserEnvVar = config.LookupUserEnvVar
7474
var openExternalResource = tuiinfra.OpenExternalResource
7575

76+
type startupWakeSubmitMsg struct {
77+
Input startupWakeSubmitInput
78+
}
79+
80+
// emitStartupWakeSubmitCmd 在启动阶段投递一次性自动提交消息,用于复用普通输入提交流程。
81+
func emitStartupWakeSubmitCmd(input startupWakeSubmitInput) tea.Cmd {
82+
return func() tea.Msg {
83+
return startupWakeSubmitMsg{Input: input}
84+
}
85+
}
86+
7687
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
7788
var cmds []tea.Cmd
7889
var spinCmd tea.Cmd
@@ -115,6 +126,11 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115126
cmds = append(cmds, appTickCmd())
116127
}
117128
return a, batchUpdateCmds()
129+
case startupWakeSubmitMsg:
130+
if cmd := a.handleStartupWakeSubmitMsg(typed); cmd != nil {
131+
cmds = append(cmds, cmd)
132+
}
133+
return a, batchUpdateCmds()
118134
case providerAddResultMsg:
119135
a.handleProviderAddResultMsg(typed)
120136
return a, batchUpdateCmds()
@@ -612,13 +628,9 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te
612628

613629
// image capability precheck is intentionally disabled.
614630
// Keep CLI behavior consistent and let runtime own capability validation.
615-
a.input.Reset()
616-
a.state.InputText = ""
617-
a.applyComponentLayout(true)
618-
a.refreshCommandMenu()
619-
a.resetPasteHeuristics()
620-
621-
cmds = append(cmds, a.beginAgentRun(input, images))
631+
if cmd := a.beginAgentRun(input, images); cmd != nil {
632+
cmds = append(cmds, cmd)
633+
}
622634
a.clearImageAttachments()
623635
return a, batchUpdateCmds()
624636
}
@@ -1169,6 +1181,16 @@ func (a *App) dispatchQueuedInterventionIfIdle() tea.Cmd {
11691181
}
11701182

11711183
func (a *App) beginAgentRun(input string, images []tuiservices.UserImageInput) tea.Cmd {
1184+
normalizedInput := strings.TrimSpace(input)
1185+
if normalizedInput == "" {
1186+
return nil
1187+
}
1188+
a.input.Reset()
1189+
a.state.InputText = ""
1190+
a.applyComponentLayout(true)
1191+
a.refreshCommandMenu()
1192+
a.resetPasteHeuristics()
1193+
11721194
a.clearActivities()
11731195
a.clearRunProgress()
11741196
a.startupScreenLocked = false
@@ -1187,7 +1209,7 @@ func (a *App) beginAgentRun(input string, images []tuiservices.UserImageInput) t
11871209
SessionID: a.state.ActiveSessionID,
11881210
RunID: runID,
11891211
Workdir: requestedWorkdir,
1190-
Text: input,
1212+
Text: normalizedInput,
11911213
Images: clonedImages,
11921214
})
11931215
}
@@ -1760,6 +1782,19 @@ func (a *App) HydrateSession(ctx context.Context, sessionID string) error {
17601782
return nil
17611783
}
17621784

1785+
// ConfigureStartupWakeInput 配置启动阶段的一次性自动提交输入,不会直接触发 runtime 调用。
1786+
func (a *App) ConfigureStartupWakeInput(text string, workdir string) error {
1787+
normalizedText := strings.TrimSpace(text)
1788+
if normalizedText == "" {
1789+
return fmt.Errorf("startup wake input is empty")
1790+
}
1791+
a.startupWakeSubmitInput = &startupWakeSubmitInput{
1792+
Text: normalizedText,
1793+
Workdir: strings.TrimSpace(workdir),
1794+
}
1795+
return nil
1796+
}
1797+
17631798
// applySessionSnapshot 将会话快照同步到前端状态,统一复用于普通刷新与启动水化路径。
17641799
func (a *App) applySessionSnapshot(session agentsession.Session, warnOnMissingWorkdir bool) {
17651800
a.activeMessages = session.Messages
@@ -3869,6 +3904,26 @@ func (a *App) requestModelCatalogRefresh(providerID string) tea.Cmd {
38693904
return runModelCatalogRefresh(a.providerSvc, providerID)
38703905
}
38713906

3907+
// handleStartupWakeSubmitMsg 处理启动期的一次性唤醒输入,并沿用普通发送链路触发 Submit。
3908+
func (a *App) handleStartupWakeSubmitMsg(msg startupWakeSubmitMsg) tea.Cmd {
3909+
a.startupWakeSubmitInput = nil
3910+
text := strings.TrimSpace(msg.Input.Text)
3911+
if text == "" {
3912+
return nil
3913+
}
3914+
if workdir := strings.TrimSpace(msg.Input.Workdir); workdir != "" {
3915+
a.setCurrentWorkdir(workdir)
3916+
if err := a.refreshFileCandidates(); err != nil {
3917+
a.state.ExecutionError = err.Error()
3918+
a.state.StatusText = err.Error()
3919+
return nil
3920+
}
3921+
}
3922+
a.input.SetValue(text)
3923+
a.state.InputText = text
3924+
return a.beginAgentRun(text, nil)
3925+
}
3926+
38723927
func ListenForRuntimeEvent(sub <-chan tuiservices.RuntimeEvent) tea.Cmd {
38733928
return tuiservices.ListenForRuntimeEventCmd(
38743929
sub,

0 commit comments

Comments
 (0)